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,411 @@
1
+ """
2
+ Tonie file operations module
3
+ """
4
+
5
+ import datetime
6
+ import hashlib
7
+ import math
8
+ import struct
9
+ import time
10
+ import os
11
+
12
+ from . import tonie_header_pb2
13
+ from .opus_packet import OpusPacket
14
+ from .ogg_page import OggPage
15
+ from .constants import OPUS_TAGS, SAMPLE_RATE_KHZ
16
+ from .logger import get_logger
17
+
18
+ # Setup logging
19
+ logger = get_logger('tonie_file')
20
+
21
+
22
+ def check_identification_header(page):
23
+ """
24
+ Check if a page contains a valid Opus identification header.
25
+
26
+ Args:
27
+ page: OggPage to check
28
+
29
+ Raises:
30
+ AssertionError: If the header is invalid or unsupported
31
+ """
32
+ segment = page.segments[0]
33
+ unpacked = struct.unpack("<8sBBHLH", segment.data[0:18])
34
+ logger.debug("Checking Opus identification header")
35
+
36
+ if unpacked[0] != b"OpusHead":
37
+ logger.error("Invalid opus file: OpusHead signature not found")
38
+ assert unpacked[0] == b"OpusHead", "Invalid opus file?"
39
+
40
+ if unpacked[1] != 1:
41
+ logger.error("Invalid opus file: Version mismatch")
42
+ assert unpacked[1] == 1, "Invalid opus file?"
43
+
44
+ if unpacked[2] != 2:
45
+ logger.error("Only stereo tracks are supported, found channel count: %d", unpacked[2])
46
+ assert unpacked[2] == 2, "Only stereo tracks are supported"
47
+
48
+ if unpacked[4] != SAMPLE_RATE_KHZ * 1000:
49
+ logger.error("Sample rate needs to be 48 kHz, found: %d Hz", unpacked[4])
50
+ assert unpacked[4] == SAMPLE_RATE_KHZ * 1000, "Sample rate needs to be 48 kHz"
51
+
52
+ logger.debug("Opus identification header is valid")
53
+
54
+
55
+ def prepare_opus_tags(page):
56
+ """
57
+ Prepare standard Opus tags for a Tonie file.
58
+
59
+ Args:
60
+ page: OggPage to modify
61
+
62
+ Returns:
63
+ OggPage: Modified page with Tonie-compatible Opus tags
64
+ """
65
+ logger.debug("Preparing Opus tags for Tonie compatibility")
66
+ page.segments.clear()
67
+ segment = OpusPacket(None)
68
+ segment.size = len(OPUS_TAGS[0])
69
+ segment.data = bytearray(OPUS_TAGS[0])
70
+ segment.spanning_packet = True
71
+ segment.first_packet = True
72
+ page.segments.append(segment)
73
+
74
+ segment = OpusPacket(None)
75
+ segment.size = len(OPUS_TAGS[1])
76
+ segment.data = bytearray(OPUS_TAGS[1])
77
+ segment.spanning_packet = False
78
+ segment.first_packet = False
79
+ page.segments.append(segment)
80
+ page.correct_values(0)
81
+ logger.trace("Opus tags prepared with %d segments", len(page.segments))
82
+ return page
83
+
84
+
85
+ def copy_first_and_second_page(in_file, out_file, timestamp, sha):
86
+ """
87
+ Copy and modify the first two pages of an Opus file for a Tonie file.
88
+
89
+ Args:
90
+ in_file: Input file handle
91
+ out_file: Output file handle
92
+ timestamp: Timestamp to use for the Tonie file
93
+ sha: SHA1 hash object to update with written data
94
+
95
+ Raises:
96
+ RuntimeError: If OGG pages cannot be found
97
+ """
98
+ logger.debug("Copying first and second pages with timestamp %d", timestamp)
99
+ found = OggPage.seek_to_page_header(in_file)
100
+ if not found:
101
+ logger.error("First OGG page not found in input file")
102
+ raise RuntimeError("First ogg page not found")
103
+
104
+ page = OggPage(in_file)
105
+ page.serial_no = timestamp
106
+ page.checksum = page.calc_checksum()
107
+ check_identification_header(page)
108
+ page.write_page(out_file, sha)
109
+ logger.debug("First page written successfully")
110
+
111
+ found = OggPage.seek_to_page_header(in_file)
112
+ if not found:
113
+ logger.error("Second OGG page not found in input file")
114
+ raise RuntimeError("Second ogg page not found")
115
+
116
+ page = OggPage(in_file)
117
+ page.serial_no = timestamp
118
+ page.checksum = page.calc_checksum()
119
+ page = prepare_opus_tags(page)
120
+ page.write_page(out_file, sha)
121
+ logger.debug("Second page written successfully")
122
+
123
+
124
+ def skip_first_two_pages(in_file):
125
+ """
126
+ Skip the first two pages of an Opus file.
127
+
128
+ Args:
129
+ in_file: Input file handle
130
+
131
+ Raises:
132
+ RuntimeError: If OGG pages cannot be found
133
+ """
134
+ logger.debug("Skipping first two pages")
135
+ found = OggPage.seek_to_page_header(in_file)
136
+ if not found:
137
+ logger.error("First OGG page not found in input file")
138
+ raise RuntimeError("First ogg page not found")
139
+
140
+ page = OggPage(in_file)
141
+ check_identification_header(page)
142
+
143
+ found = OggPage.seek_to_page_header(in_file)
144
+ if not found:
145
+ logger.error("Second OGG page not found in input file")
146
+ raise RuntimeError("Second ogg page not found")
147
+
148
+ OggPage(in_file)
149
+ logger.debug("First two pages skipped successfully")
150
+
151
+
152
+ def read_all_remaining_pages(in_file):
153
+ """
154
+ Read all remaining OGG pages from an input file.
155
+
156
+ Args:
157
+ in_file: Input file handle
158
+
159
+ Returns:
160
+ list: List of OggPage objects
161
+ """
162
+ logger.debug("Reading all remaining OGG pages")
163
+ remaining_pages = []
164
+
165
+ found = OggPage.seek_to_page_header(in_file)
166
+ page_count = 0
167
+
168
+ while found:
169
+ remaining_pages.append(OggPage(in_file))
170
+ page_count += 1
171
+ found = OggPage.seek_to_page_header(in_file)
172
+
173
+ logger.debug("Read %d remaining OGG pages", page_count)
174
+ return remaining_pages
175
+
176
+
177
+ def resize_pages(old_pages, max_page_size, first_page_size, template_page, last_granule=0, start_no=2,
178
+ set_last_page_flag=False):
179
+ """
180
+ Resize OGG pages to fit Tonie requirements.
181
+
182
+ Args:
183
+ old_pages: List of original OggPage objects
184
+ max_page_size: Maximum size for pages
185
+ first_page_size: Size for the first page
186
+ template_page: Template OggPage to use for creating new pages
187
+ last_granule: Last granule position
188
+ start_no: Starting page number
189
+ set_last_page_flag: Whether to set the last page flag
190
+
191
+ Returns:
192
+ list: List of resized OggPage objects
193
+ """
194
+ logger.debug("Resizing %d OGG pages (max_size=%d, first_size=%d, start_no=%d)",
195
+ len(old_pages), max_page_size, first_page_size, start_no)
196
+
197
+ new_pages = []
198
+ page = None
199
+ page_no = start_no
200
+ max_size = first_page_size
201
+
202
+ new_page = OggPage.from_page(template_page)
203
+ new_page.page_no = page_no
204
+
205
+ while len(old_pages) or not (page is None):
206
+ if page is None:
207
+ page = old_pages.pop(0)
208
+
209
+ size = page.get_size_of_first_opus_packet()
210
+ seg_count = page.get_segment_count_of_first_opus_packet()
211
+
212
+ if (size + seg_count + new_page.get_page_size() <= max_size) and (len(new_page.segments) + seg_count < 256):
213
+ for i in range(seg_count):
214
+ new_page.segments.append(page.segments.pop(0))
215
+ if not len(page.segments):
216
+ page = None
217
+ else:
218
+ new_page.pad(max_size)
219
+ new_page.correct_values(last_granule)
220
+ last_granule = new_page.granule_position
221
+ new_pages.append(new_page)
222
+ logger.trace("Created new page #%d with %d segments", page_no, len(new_page.segments))
223
+
224
+ new_page = OggPage.from_page(template_page)
225
+ page_no = page_no + 1
226
+ new_page.page_no = page_no
227
+ max_size = max_page_size
228
+
229
+ if len(new_page.segments):
230
+ if set_last_page_flag:
231
+ new_page.page_type = 4
232
+ logger.debug("Setting last page flag on page #%d", page_no)
233
+
234
+ new_page.pad(max_size)
235
+ new_page.correct_values(last_granule)
236
+ new_pages.append(new_page)
237
+ logger.trace("Created final page #%d with %d segments", page_no, len(new_page.segments))
238
+
239
+ logger.debug("Resized to %d OGG pages", len(new_pages))
240
+ return new_pages
241
+
242
+
243
+ def fix_tonie_header(out_file, chapters, timestamp, sha):
244
+ """
245
+ Fix the Tonie header in a file.
246
+
247
+ Args:
248
+ out_file: Output file handle
249
+ chapters: List of chapter page numbers
250
+ timestamp: Timestamp for the Tonie file
251
+ sha: SHA1 hash object with file content
252
+ """
253
+ logger.info("Writing Tonie header with %d chapters and timestamp %d", len(chapters), timestamp)
254
+ tonie_header = tonie_header_pb2.TonieHeader()
255
+
256
+ tonie_header.dataHash = sha.digest()
257
+ data_length = out_file.seek(0, 1) - 0x1000
258
+ tonie_header.dataLength = data_length
259
+ tonie_header.timestamp = timestamp
260
+ logger.debug("Data length: %d bytes, SHA1: %s", data_length, sha.hexdigest())
261
+
262
+ for chapter in chapters:
263
+ tonie_header.chapterPages.append(chapter)
264
+ logger.trace("Added chapter at page %d", chapter)
265
+
266
+ tonie_header.padding = bytes(0x100)
267
+
268
+ header = tonie_header.SerializeToString()
269
+ pad = 0xFFC - len(header) + 0x100
270
+ tonie_header.padding = bytes(pad)
271
+ header = tonie_header.SerializeToString()
272
+
273
+ out_file.seek(0)
274
+ out_file.write(struct.pack(">L", len(header)))
275
+ out_file.write(header)
276
+ logger.debug("Tonie header written successfully (size: %d bytes)", len(header))
277
+
278
+
279
+ def create_tonie_file(output_file, input_files, no_tonie_header=False, user_timestamp=None,
280
+ bitrate=96, vbr=True, ffmpeg_binary=None, opus_binary=None, keep_temp=False):
281
+ """
282
+ Create a Tonie file from input files.
283
+
284
+ Args:
285
+ output_file: Output file path
286
+ input_files: List of input file paths
287
+ no_tonie_header: Whether to omit the Tonie header
288
+ user_timestamp: Custom timestamp to use
289
+ bitrate: Bitrate for encoding in kbps
290
+ vbr: Whether to use variable bitrate encoding (True) or constant (False)
291
+ ffmpeg_binary: Path to ffmpeg binary
292
+ opus_binary: Path to opusenc binary
293
+ keep_temp: Whether to keep temporary opus files for testing
294
+ """
295
+ from .audio_conversion import get_opus_tempfile
296
+
297
+ logger.info("Creating Tonie file from %d input files", len(input_files))
298
+ logger.debug("Output file: %s, Bitrate: %d kbps, VBR: %s, No header: %s",
299
+ output_file, bitrate, vbr, no_tonie_header)
300
+
301
+ temp_files = [] # Keep track of temporary files created
302
+
303
+ with open(output_file, "wb") as out_file:
304
+ if not no_tonie_header:
305
+ logger.debug("Reserving space for Tonie header (0x1000 bytes)")
306
+ out_file.write(bytearray(0x1000))
307
+
308
+ if user_timestamp is not None:
309
+ if os.path.isfile(user_timestamp) and user_timestamp.lower().endswith('.taf'):
310
+ logger.debug("Extracting timestamp from Tonie file: %s", user_timestamp)
311
+ from .tonie_analysis import get_header_info
312
+ try:
313
+ with open(user_timestamp, "rb") as taf_file:
314
+ _, tonie_header, _, _, _, _, _, _, _, bitstream_serial_no = get_header_info(taf_file)
315
+ timestamp = bitstream_serial_no
316
+ logger.debug("Extracted timestamp from Tonie file: %d", timestamp)
317
+ except Exception as e:
318
+ logger.error("Failed to extract timestamp from Tonie file: %s", str(e))
319
+ timestamp = int(time.time())
320
+ logger.debug("Falling back to current timestamp: %d", timestamp)
321
+ elif user_timestamp.startswith("0x"):
322
+ timestamp = int(user_timestamp, 16)
323
+ logger.debug("Using user-provided hexadecimal timestamp: %d", timestamp)
324
+ else:
325
+ try:
326
+ timestamp = int(user_timestamp)
327
+ logger.debug("Using user-provided decimal timestamp: %d", timestamp)
328
+ except ValueError:
329
+ logger.error("Invalid timestamp format: %s", user_timestamp)
330
+ timestamp = int(time.time())
331
+ logger.debug("Falling back to current timestamp: %d", timestamp)
332
+ else:
333
+ timestamp = int(time.time())
334
+ logger.debug("Using current timestamp: %d", timestamp)
335
+
336
+ sha1 = hashlib.sha1()
337
+
338
+ template_page = None
339
+ chapters = []
340
+ total_granule = 0
341
+ next_page_no = 2
342
+ max_size = 0x1000
343
+ other_size = 0xE00
344
+ last_track = False
345
+
346
+ pad_len = math.ceil(math.log(len(input_files) + 1, 10))
347
+ format_string = "[{{:0{}d}}/{:0{}d}] {{}}".format(pad_len, len(input_files), pad_len)
348
+
349
+ for index in range(len(input_files)):
350
+ fname = input_files[index]
351
+ logger.info(format_string.format(index + 1, fname))
352
+ if index == len(input_files) - 1:
353
+ last_track = True
354
+ logger.debug("Processing last track")
355
+
356
+ if fname.lower().endswith(".opus"):
357
+ logger.debug("Input is already in Opus format")
358
+ handle = open(fname, "rb")
359
+ temp_file_path = None
360
+ else:
361
+ logger.debug("Converting %s to Opus format (bitrate: %d kbps, VBR: %s)",
362
+ fname, bitrate, vbr)
363
+ handle, temp_file_path = get_opus_tempfile(ffmpeg_binary, opus_binary, fname, bitrate, vbr, keep_temp)
364
+ if temp_file_path:
365
+ temp_files.append(temp_file_path)
366
+ logger.debug("Temporary opus file saved to: %s", temp_file_path)
367
+
368
+ try:
369
+ if next_page_no == 2:
370
+ logger.debug("Processing first file: copying first and second page")
371
+ copy_first_and_second_page(handle, out_file, timestamp, sha1)
372
+ else:
373
+ logger.debug("Processing subsequent file: skipping first and second page")
374
+ other_size = max_size
375
+ skip_first_two_pages(handle)
376
+
377
+ logger.debug("Reading remaining pages from file")
378
+ pages = read_all_remaining_pages(handle)
379
+
380
+ if template_page is None:
381
+ template_page = OggPage.from_page(pages[0])
382
+ template_page.serial_no = timestamp
383
+ logger.debug("Created template page with serial no %d", timestamp)
384
+
385
+ if next_page_no == 2:
386
+ chapters.append(0)
387
+ logger.debug("Added first chapter at page 0")
388
+ else:
389
+ chapters.append(next_page_no)
390
+ logger.debug("Added chapter at page %d", next_page_no)
391
+
392
+ logger.debug("Resizing pages for track %d", index + 1)
393
+ new_pages = resize_pages(pages, max_size, other_size, template_page,
394
+ total_granule, next_page_no, last_track)
395
+
396
+ for new_page in new_pages:
397
+ new_page.write_page(out_file, sha1)
398
+
399
+ last_page = new_pages[len(new_pages) - 1]
400
+ total_granule = last_page.granule_position
401
+ next_page_no = last_page.page_no + 1
402
+ logger.debug("Track %d processed, next page no: %d, total granule: %d",
403
+ index + 1, next_page_no, total_granule)
404
+ finally:
405
+ handle.close()
406
+
407
+ if not no_tonie_header:
408
+ fix_tonie_header(out_file, chapters, timestamp, sha1)
409
+
410
+ if keep_temp and temp_files:
411
+ logger.info("Kept %d temporary opus files in %s", len(temp_files), os.path.dirname(temp_files[0]))
@@ -0,0 +1,11 @@
1
+ syntax = 'proto3';
2
+
3
+ package tonie;
4
+
5
+ message TonieHeader {
6
+ bytes dataHash = 1;
7
+ uint32 dataLength = 2;
8
+ uint32 timestamp = 3;
9
+ repeated uint32 chapterPages = 4 [packed=true];
10
+ bytes padding = 5;
11
+ }
@@ -0,0 +1,99 @@
1
+ # Generated by the protocol buffer compiler. DO NOT EDIT!
2
+ # source: tonie_header.proto
3
+
4
+ import sys
5
+ _b=sys.version_info[0]<3 and (lambda x:x) or (lambda x:x.encode('latin1'))
6
+ from google.protobuf import descriptor as _descriptor
7
+ from google.protobuf import message as _message
8
+ from google.protobuf import reflection as _reflection
9
+ from google.protobuf import symbol_database as _symbol_database
10
+ from google.protobuf import descriptor_pb2
11
+ # @@protoc_insertion_point(imports)
12
+
13
+ _sym_db = _symbol_database.Default()
14
+
15
+
16
+
17
+
18
+ DESCRIPTOR = _descriptor.FileDescriptor(
19
+ name='tonie_header.proto',
20
+ package='tonie',
21
+ syntax='proto3',
22
+ serialized_pb=_b('\n\x12tonie_header.proto\x12\x05tonie\"q\n\x0bTonieHeader\x12\x10\n\x08\x64\x61taHash\x18\x01 \x01(\x0c\x12\x12\n\ndataLength\x18\x02 \x01(\r\x12\x11\n\ttimestamp\x18\x03 \x01(\r\x12\x18\n\x0c\x63hapterPages\x18\x04 \x03(\rB\x02\x10\x01\x12\x0f\n\x07padding\x18\x05 \x01(\x0c\x62\x06proto3')
23
+ )
24
+ _sym_db.RegisterFileDescriptor(DESCRIPTOR)
25
+
26
+
27
+
28
+
29
+ _TONIEHEADER = _descriptor.Descriptor(
30
+ name='TonieHeader',
31
+ full_name='tonie.TonieHeader',
32
+ filename=None,
33
+ file=DESCRIPTOR,
34
+ containing_type=None,
35
+ fields=[
36
+ _descriptor.FieldDescriptor(
37
+ name='dataHash', full_name='tonie.TonieHeader.dataHash', index=0,
38
+ number=1, type=12, cpp_type=9, label=1,
39
+ has_default_value=False, default_value=_b(""),
40
+ message_type=None, enum_type=None, containing_type=None,
41
+ is_extension=False, extension_scope=None,
42
+ options=None),
43
+ _descriptor.FieldDescriptor(
44
+ name='dataLength', full_name='tonie.TonieHeader.dataLength', index=1,
45
+ number=2, type=13, cpp_type=3, label=1,
46
+ has_default_value=False, default_value=0,
47
+ message_type=None, enum_type=None, containing_type=None,
48
+ is_extension=False, extension_scope=None,
49
+ options=None),
50
+ _descriptor.FieldDescriptor(
51
+ name='timestamp', full_name='tonie.TonieHeader.timestamp', index=2,
52
+ number=3, type=13, cpp_type=3, label=1,
53
+ has_default_value=False, default_value=0,
54
+ message_type=None, enum_type=None, containing_type=None,
55
+ is_extension=False, extension_scope=None,
56
+ options=None),
57
+ _descriptor.FieldDescriptor(
58
+ name='chapterPages', full_name='tonie.TonieHeader.chapterPages', index=3,
59
+ number=4, type=13, cpp_type=3, label=3,
60
+ has_default_value=False, default_value=[],
61
+ message_type=None, enum_type=None, containing_type=None,
62
+ is_extension=False, extension_scope=None,
63
+ options=_descriptor._ParseOptions(descriptor_pb2.FieldOptions(), _b('\020\001'))),
64
+ _descriptor.FieldDescriptor(
65
+ name='padding', full_name='tonie.TonieHeader.padding', index=4,
66
+ number=5, type=12, cpp_type=9, label=1,
67
+ has_default_value=False, default_value=_b(""),
68
+ message_type=None, enum_type=None, containing_type=None,
69
+ is_extension=False, extension_scope=None,
70
+ options=None),
71
+ ],
72
+ extensions=[
73
+ ],
74
+ nested_types=[],
75
+ enum_types=[
76
+ ],
77
+ options=None,
78
+ is_extendable=False,
79
+ syntax='proto3',
80
+ extension_ranges=[],
81
+ oneofs=[
82
+ ],
83
+ serialized_start=29,
84
+ serialized_end=142,
85
+ )
86
+
87
+ DESCRIPTOR.message_types_by_name['TonieHeader'] = _TONIEHEADER
88
+
89
+ TonieHeader = _reflection.GeneratedProtocolMessageType('TonieHeader', (_message.Message,), dict(
90
+ DESCRIPTOR = _TONIEHEADER,
91
+ __module__ = 'tonie_header_pb2'
92
+ # @@protoc_insertion_point(class_scope:tonie.TonieHeader)
93
+ ))
94
+ _sym_db.RegisterMessage(TonieHeader)
95
+
96
+
97
+ _TONIEHEADER.fields_by_name['chapterPages'].has_options = True
98
+ _TONIEHEADER.fields_by_name['chapterPages']._options = _descriptor._ParseOptions(descriptor_pb2.FieldOptions(), _b('\020\001'))
99
+ # @@protoc_insertion_point(module_scope)