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.
- TonieToolbox/__init__.py +5 -0
- TonieToolbox/__main__.py +145 -0
- TonieToolbox/audio_conversion.py +194 -0
- TonieToolbox/constants.py +14 -0
- TonieToolbox/dependency_manager.py +378 -0
- TonieToolbox/filename_generator.py +94 -0
- TonieToolbox/logger.py +57 -0
- TonieToolbox/ogg_page.py +588 -0
- TonieToolbox/opus_packet.py +219 -0
- TonieToolbox/tonie_analysis.py +522 -0
- TonieToolbox/tonie_file.py +411 -0
- TonieToolbox/tonie_header.proto +11 -0
- TonieToolbox/tonie_header_pb2.py +99 -0
- tonietoolbox-0.1.0.dist-info/METADATA +301 -0
- tonietoolbox-0.1.0.dist-info/RECORD +19 -0
- tonietoolbox-0.1.0.dist-info/WHEEL +5 -0
- tonietoolbox-0.1.0.dist-info/entry_points.txt +2 -0
- tonietoolbox-0.1.0.dist-info/licenses/LICENSE.md +674 -0
- tonietoolbox-0.1.0.dist-info/top_level.txt +1 -0
TonieToolbox/__init__.py
ADDED
TonieToolbox/__main__.py
ADDED
@@ -0,0 +1,145 @@
|
|
1
|
+
#!/usr/bin/python3
|
2
|
+
"""
|
3
|
+
Main entry point for the TonieToolbox package.
|
4
|
+
"""
|
5
|
+
|
6
|
+
import argparse
|
7
|
+
import os
|
8
|
+
import sys
|
9
|
+
import logging
|
10
|
+
|
11
|
+
from . import __version__
|
12
|
+
from .audio_conversion import get_input_files, append_to_filename
|
13
|
+
from .tonie_file import create_tonie_file
|
14
|
+
from .tonie_analysis import check_tonie_file, split_to_opus_files
|
15
|
+
from .dependency_manager import get_ffmpeg_binary, get_opus_binary
|
16
|
+
from .logger import setup_logging, get_logger
|
17
|
+
from .filename_generator import guess_output_filename
|
18
|
+
|
19
|
+
def main():
|
20
|
+
"""Entry point for the TonieToolbox application."""
|
21
|
+
parser = argparse.ArgumentParser(description='Create Tonie compatible file from Ogg opus file(s).')
|
22
|
+
parser.add_argument('--version', action='version', version=f'TonieToolbox {__version__}',
|
23
|
+
help='show program version and exit')
|
24
|
+
parser.add_argument('input_filename', metavar='SOURCE', type=str,
|
25
|
+
help='input file or directory or a file list (.lst)')
|
26
|
+
parser.add_argument('output_filename', metavar='TARGET', nargs='?', type=str,
|
27
|
+
help='the output file name (default: ---ID---)')
|
28
|
+
parser.add_argument('--ts', dest='user_timestamp', metavar='TIMESTAMP', action='store',
|
29
|
+
help='set custom timestamp / bitstream serial')
|
30
|
+
|
31
|
+
parser.add_argument('--ffmpeg', help='specify location of ffmpeg', default=None)
|
32
|
+
parser.add_argument('--opusenc', help='specify location of opusenc', default=None)
|
33
|
+
parser.add_argument('--bitrate', type=int, help='set encoding bitrate in kbps (default: 96)', default=96)
|
34
|
+
parser.add_argument('--cbr', action='store_true', help='encode in cbr mode')
|
35
|
+
parser.add_argument('--append-tonie-tag', metavar='TAG', action='store',
|
36
|
+
help='append [TAG] to filename (must be an 8-character hex value)')
|
37
|
+
parser.add_argument('--no-tonie-header', action='store_true', help='do not write Tonie header')
|
38
|
+
parser.add_argument('--info', action='store_true', help='Check and display info about Tonie file')
|
39
|
+
parser.add_argument('--split', action='store_true', help='Split Tonie file into opus tracks')
|
40
|
+
parser.add_argument('--auto-download', action='store_true', help='Automatically download FFmpeg and opusenc if needed')
|
41
|
+
parser.add_argument('--keep-temp', action='store_true',
|
42
|
+
help='Keep temporary opus files in a temp folder for testing')
|
43
|
+
parser.add_argument('--compare', action='store', metavar='FILE2',
|
44
|
+
help='Compare input file with another .taf file for debugging')
|
45
|
+
parser.add_argument('--detailed-compare', action='store_true',
|
46
|
+
help='Show detailed OGG page differences when comparing files')
|
47
|
+
|
48
|
+
log_group = parser.add_argument_group('Logging Options')
|
49
|
+
log_level_group = log_group.add_mutually_exclusive_group()
|
50
|
+
log_level_group.add_argument('--debug', action='store_true', help='Enable debug logging')
|
51
|
+
log_level_group.add_argument('--trace', action='store_true', help='Enable trace logging (very verbose)')
|
52
|
+
log_level_group.add_argument('--quiet', action='store_true', help='Show only warnings and errors')
|
53
|
+
log_level_group.add_argument('--silent', action='store_true', help='Show only errors')
|
54
|
+
|
55
|
+
args = parser.parse_args()
|
56
|
+
if args.trace:
|
57
|
+
from .logger import TRACE
|
58
|
+
log_level = TRACE
|
59
|
+
elif args.debug:
|
60
|
+
log_level = logging.DEBUG
|
61
|
+
elif args.quiet:
|
62
|
+
log_level = logging.WARNING
|
63
|
+
elif args.silent:
|
64
|
+
log_level = logging.ERROR
|
65
|
+
else:
|
66
|
+
log_level = logging.INFO
|
67
|
+
|
68
|
+
setup_logging(log_level)
|
69
|
+
logger = get_logger('main')
|
70
|
+
logger.debug("Starting TonieToolbox with log level: %s", logging.getLevelName(log_level))
|
71
|
+
|
72
|
+
ffmpeg_binary = args.ffmpeg
|
73
|
+
if ffmpeg_binary is None:
|
74
|
+
ffmpeg_binary = get_ffmpeg_binary()
|
75
|
+
if ffmpeg_binary is None:
|
76
|
+
logger.error("Could not find FFmpeg. Please install FFmpeg or specify its location using --ffmpeg")
|
77
|
+
sys.exit(1)
|
78
|
+
logger.debug("Using FFmpeg binary: %s", ffmpeg_binary)
|
79
|
+
|
80
|
+
opus_binary = args.opusenc
|
81
|
+
if opus_binary is None:
|
82
|
+
opus_binary = get_opus_binary()
|
83
|
+
if opus_binary is None:
|
84
|
+
logger.error("Could not find opusenc. Please install opus-tools or specify its location using --opusenc")
|
85
|
+
sys.exit(1)
|
86
|
+
logger.debug("Using opusenc binary: %s", opus_binary)
|
87
|
+
|
88
|
+
if os.path.isdir(args.input_filename):
|
89
|
+
logger.debug("Input is a directory: %s", args.input_filename)
|
90
|
+
args.input_filename += "/*"
|
91
|
+
else:
|
92
|
+
logger.debug("Input is a file: %s", args.input_filename)
|
93
|
+
if args.info:
|
94
|
+
logger.info("Checking Tonie file: %s", args.input_filename)
|
95
|
+
ok = check_tonie_file(args.input_filename)
|
96
|
+
sys.exit(0 if ok else 1)
|
97
|
+
elif args.split:
|
98
|
+
logger.info("Splitting Tonie file: %s", args.input_filename)
|
99
|
+
split_to_opus_files(args.input_filename, args.output_filename)
|
100
|
+
sys.exit(0)
|
101
|
+
elif args.compare:
|
102
|
+
from .tonie_analysis import compare_taf_files
|
103
|
+
logger.info("Comparing Tonie files: %s and %s", args.input_filename, args.compare)
|
104
|
+
result = compare_taf_files(args.input_filename, args.compare, args.detailed_compare)
|
105
|
+
sys.exit(0 if result else 1)
|
106
|
+
|
107
|
+
files = get_input_files(args.input_filename)
|
108
|
+
logger.debug("Found %d files to process", len(files))
|
109
|
+
|
110
|
+
if len(files) == 0:
|
111
|
+
logger.error("No files found for pattern %s", args.input_filename)
|
112
|
+
sys.exit(1)
|
113
|
+
|
114
|
+
if args.output_filename:
|
115
|
+
out_filename = args.output_filename
|
116
|
+
else:
|
117
|
+
guessed_name = guess_output_filename(args.input_filename, files)
|
118
|
+
output_dir = './output'
|
119
|
+
if not os.path.exists(output_dir):
|
120
|
+
logger.debug("Creating default output directory: %s", output_dir)
|
121
|
+
os.makedirs(output_dir, exist_ok=True)
|
122
|
+
out_filename = os.path.join(output_dir, guessed_name)
|
123
|
+
logger.debug("Using default output location: %s", out_filename)
|
124
|
+
|
125
|
+
if args.append_tonie_tag:
|
126
|
+
# Validate that the value is an 8-character hex value
|
127
|
+
hex_tag = args.append_tonie_tag
|
128
|
+
logger.debug("Validating tag: %s", hex_tag)
|
129
|
+
if not all(c in '0123456789abcdefABCDEF' for c in hex_tag) or len(hex_tag) != 8:
|
130
|
+
logger.error("TAG must be an 8-character hexadecimal value")
|
131
|
+
sys.exit(1)
|
132
|
+
logger.debug("Appending [%s] to output filename", hex_tag)
|
133
|
+
out_filename = append_to_filename(out_filename, hex_tag)
|
134
|
+
|
135
|
+
if not out_filename.lower().endswith('.taf'):
|
136
|
+
out_filename += '.taf'
|
137
|
+
|
138
|
+
logger.info("Creating Tonie file: %s with %d input file(s)", out_filename, len(files))
|
139
|
+
create_tonie_file(out_filename, files, args.no_tonie_header, args.user_timestamp,
|
140
|
+
args.bitrate, not args.cbr, ffmpeg_binary, opus_binary, args.keep_temp)
|
141
|
+
logger.info("Successfully created Tonie file: %s", out_filename)
|
142
|
+
|
143
|
+
|
144
|
+
if __name__ == "__main__":
|
145
|
+
main()
|
@@ -0,0 +1,194 @@
|
|
1
|
+
"""
|
2
|
+
Audio conversion functionality for the TonieToolbox package
|
3
|
+
"""
|
4
|
+
|
5
|
+
import os
|
6
|
+
import glob
|
7
|
+
import subprocess
|
8
|
+
import tempfile
|
9
|
+
from .dependency_manager import get_ffmpeg_binary, get_opus_binary
|
10
|
+
from .logger import get_logger
|
11
|
+
|
12
|
+
logger = get_logger('audio_conversion')
|
13
|
+
|
14
|
+
|
15
|
+
def get_opus_tempfile(ffmpeg_binary=None, opus_binary=None, filename=None, bitrate=48, vbr=True, keep_temp=False):
|
16
|
+
"""
|
17
|
+
Convert an audio file to Opus format and return a temporary file handle.
|
18
|
+
|
19
|
+
Args:
|
20
|
+
ffmpeg_binary: Path to the ffmpeg binary. If None, will be auto-detected or downloaded.
|
21
|
+
opus_binary: Path to the opusenc binary. If None, will be auto-detected or downloaded.
|
22
|
+
filename: Path to the input audio file
|
23
|
+
bitrate: Bitrate for the Opus encoding in kbps
|
24
|
+
vbr: Whether to use variable bitrate encoding
|
25
|
+
keep_temp: Whether to keep the temporary files for testing
|
26
|
+
|
27
|
+
Returns:
|
28
|
+
tuple: (file handle, temp_file_path) or (file handle, None) if keep_temp is False
|
29
|
+
"""
|
30
|
+
logger.debug("Converting %s to Opus format (bitrate: %d kbps, vbr: %s)", filename, bitrate, vbr)
|
31
|
+
|
32
|
+
if ffmpeg_binary is None:
|
33
|
+
logger.debug("FFmpeg not specified, attempting to auto-detect")
|
34
|
+
ffmpeg_binary = get_ffmpeg_binary()
|
35
|
+
if ffmpeg_binary is None:
|
36
|
+
logger.error("Could not find or download FFmpeg binary")
|
37
|
+
raise RuntimeError("Could not find or download FFmpeg binary")
|
38
|
+
logger.debug("Found FFmpeg at: %s", ffmpeg_binary)
|
39
|
+
|
40
|
+
if opus_binary is None:
|
41
|
+
logger.debug("Opusenc not specified, attempting to auto-detect")
|
42
|
+
opus_binary = get_opus_binary()
|
43
|
+
if opus_binary is None:
|
44
|
+
logger.error("Could not find or download Opus binary")
|
45
|
+
raise RuntimeError("Could not find or download Opus binary")
|
46
|
+
logger.debug("Found opusenc at: %s", opus_binary)
|
47
|
+
|
48
|
+
vbr_parameter = "--vbr" if vbr else "--hard-cbr"
|
49
|
+
logger.debug("Using encoding parameter: %s", vbr_parameter)
|
50
|
+
|
51
|
+
temp_path = None
|
52
|
+
if keep_temp:
|
53
|
+
temp_dir = os.path.join(tempfile.gettempdir(), "tonie_toolbox_temp")
|
54
|
+
os.makedirs(temp_dir, exist_ok=True)
|
55
|
+
base_filename = os.path.basename(filename)
|
56
|
+
temp_path = os.path.join(temp_dir, f"{os.path.splitext(base_filename)[0]}_{bitrate}kbps.opus")
|
57
|
+
logger.info("Creating persistent temporary file: %s", temp_path)
|
58
|
+
|
59
|
+
logger.debug("Starting FFmpeg process")
|
60
|
+
ffmpeg_process = subprocess.Popen(
|
61
|
+
[ffmpeg_binary, "-hide_banner", "-loglevel", "warning", "-i", filename, "-f", "wav",
|
62
|
+
"-ar", "48000", "-"], stdout=subprocess.PIPE)
|
63
|
+
|
64
|
+
logger.debug("Starting opusenc process")
|
65
|
+
opusenc_process = subprocess.Popen(
|
66
|
+
[opus_binary, "--quiet", vbr_parameter, "--bitrate", f"{bitrate:d}", "-", temp_path],
|
67
|
+
stdin=ffmpeg_process.stdout, stderr=subprocess.DEVNULL)
|
68
|
+
|
69
|
+
opusenc_process.communicate()
|
70
|
+
|
71
|
+
if opusenc_process.returncode != 0:
|
72
|
+
logger.error("Opus encoding failed with return code %d", opusenc_process.returncode)
|
73
|
+
raise RuntimeError(f"Opus encoding failed with return code {opusenc_process.returncode}")
|
74
|
+
|
75
|
+
logger.debug("Opening temporary file for reading")
|
76
|
+
tmp_file = open(temp_path, "rb")
|
77
|
+
return tmp_file, temp_path
|
78
|
+
else:
|
79
|
+
logger.debug("Using in-memory temporary file")
|
80
|
+
|
81
|
+
logger.debug("Starting FFmpeg process")
|
82
|
+
ffmpeg_process = subprocess.Popen(
|
83
|
+
[ffmpeg_binary, "-hide_banner", "-loglevel", "warning", "-i", filename, "-f", "wav",
|
84
|
+
"-ar", "48000", "-"], stdout=subprocess.PIPE)
|
85
|
+
|
86
|
+
logger.debug("Starting opusenc process")
|
87
|
+
opusenc_process = subprocess.Popen(
|
88
|
+
[opus_binary, "--quiet", vbr_parameter, "--bitrate", f"{bitrate:d}", "-", "-"],
|
89
|
+
stdin=ffmpeg_process.stdout, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
90
|
+
|
91
|
+
tmp_file = tempfile.SpooledTemporaryFile()
|
92
|
+
bytes_written = 0
|
93
|
+
|
94
|
+
logger.debug("Reading opusenc output")
|
95
|
+
for chunk in iter(lambda: opusenc_process.stdout.read(4096), b""):
|
96
|
+
tmp_file.write(chunk)
|
97
|
+
bytes_written += len(chunk)
|
98
|
+
|
99
|
+
if opusenc_process.wait() != 0:
|
100
|
+
logger.error("Opus encoding failed with return code %d", opusenc_process.returncode)
|
101
|
+
raise RuntimeError(f"Opus encoding failed with return code {opusenc_process.returncode}")
|
102
|
+
|
103
|
+
logger.debug("Wrote %d bytes to temporary file", bytes_written)
|
104
|
+
tmp_file.seek(0)
|
105
|
+
|
106
|
+
return tmp_file, None
|
107
|
+
|
108
|
+
|
109
|
+
def filter_directories(glob_list):
|
110
|
+
"""
|
111
|
+
Filter a list of glob results to include only audio files that can be handled by ffmpeg.
|
112
|
+
|
113
|
+
Args:
|
114
|
+
glob_list: List of path names from glob.glob()
|
115
|
+
|
116
|
+
Returns:
|
117
|
+
list: Filtered list containing only supported audio files
|
118
|
+
"""
|
119
|
+
logger.debug("Filtering %d glob results for supported audio files", len(glob_list))
|
120
|
+
|
121
|
+
# Common audio file extensions supported by ffmpeg
|
122
|
+
supported_extensions = [
|
123
|
+
'.wav', '.mp3', '.aac', '.m4a', '.flac', '.ogg', '.opus',
|
124
|
+
'.ape', '.wma', '.aiff', '.mp2', '.mp4', '.webm', '.mka'
|
125
|
+
]
|
126
|
+
|
127
|
+
filtered = []
|
128
|
+
for name in glob_list:
|
129
|
+
if os.path.isfile(name):
|
130
|
+
ext = os.path.splitext(name)[1].lower()
|
131
|
+
if ext in supported_extensions:
|
132
|
+
filtered.append(name)
|
133
|
+
logger.trace("Added supported audio file: %s", name)
|
134
|
+
else:
|
135
|
+
logger.trace("Skipping unsupported file: %s", name)
|
136
|
+
|
137
|
+
logger.debug("Found %d supported audio files after filtering", len(filtered))
|
138
|
+
return filtered
|
139
|
+
|
140
|
+
|
141
|
+
def get_input_files(input_filename):
|
142
|
+
"""
|
143
|
+
Get a list of input files to process.
|
144
|
+
|
145
|
+
Supports direct file paths, directory paths, glob patterns, and .lst files.
|
146
|
+
|
147
|
+
Args:
|
148
|
+
input_filename: Input file pattern or list file path
|
149
|
+
|
150
|
+
Returns:
|
151
|
+
list: List of input file paths
|
152
|
+
"""
|
153
|
+
logger.debug("Getting input files for pattern: %s", input_filename)
|
154
|
+
|
155
|
+
if input_filename.endswith(".lst"):
|
156
|
+
logger.debug("Processing list file: %s", input_filename)
|
157
|
+
list_dir = os.path.dirname(os.path.abspath(input_filename))
|
158
|
+
input_files = []
|
159
|
+
with open(input_filename) as file_list:
|
160
|
+
for line in file_list:
|
161
|
+
fname = line.rstrip()
|
162
|
+
full_path = os.path.join(list_dir, fname)
|
163
|
+
input_files.append(full_path)
|
164
|
+
logger.trace("Added file from list: %s", full_path)
|
165
|
+
logger.debug("Found %d files in list file", len(input_files))
|
166
|
+
else:
|
167
|
+
logger.debug("Processing glob pattern: %s", input_filename)
|
168
|
+
input_files = sorted(filter_directories(glob.glob(input_filename)))
|
169
|
+
logger.debug("Found %d files matching pattern", len(input_files))
|
170
|
+
|
171
|
+
return input_files
|
172
|
+
|
173
|
+
|
174
|
+
def append_to_filename(output_filename, tag):
|
175
|
+
"""
|
176
|
+
Append a tag to a filename, preserving the extension.
|
177
|
+
|
178
|
+
Args:
|
179
|
+
output_filename: Original filename
|
180
|
+
tag: Tag to append (typically an 8-character hex value)
|
181
|
+
|
182
|
+
Returns:
|
183
|
+
str: Modified filename with tag
|
184
|
+
"""
|
185
|
+
logger.debug("Appending tag '%s' to filename: %s", tag, output_filename)
|
186
|
+
pos = output_filename.rfind('.')
|
187
|
+
if pos == -1:
|
188
|
+
result = f"{output_filename}_{tag}"
|
189
|
+
logger.debug("No extension found, result: %s", result)
|
190
|
+
return result
|
191
|
+
else:
|
192
|
+
result = f"{output_filename[:pos]}_{tag}{output_filename[pos:]}"
|
193
|
+
logger.debug("Extension found, result: %s", result)
|
194
|
+
return result
|
@@ -0,0 +1,14 @@
|
|
1
|
+
"""
|
2
|
+
Constants used throughout the TonieToolbox package
|
3
|
+
"""
|
4
|
+
SAMPLE_RATE_KHZ = 48
|
5
|
+
ONLY_CONVERT_FRAMEPACKING = -1
|
6
|
+
OTHER_PACKET_NEEDED = -2
|
7
|
+
DO_NOTHING = -3
|
8
|
+
TOO_MANY_SEGMENTS = -4
|
9
|
+
OPUS_TAGS = [
|
10
|
+
bytearray(
|
11
|
+
b"\x4F\x70\x75\x73\x54\x61\x67\x73\x0D\x00\x00\x00\x4C\x61\x76\x66\x35\x38\x2E\x32\x30\x2E\x31\x30\x30\x03\x00\x00\x00\x26\x00\x00\x00\x65\x6E\x63\x6F\x64\x65\x72\x3D\x6F\x70\x75\x73\x65\x6E\x63\x20\x66\x72\x6F\x6D\x20\x6F\x70\x75\x73\x2D\x74\x6F\x6F\x6C\x73\x20\x30\x2E\x31\x2E\x31\x30\x2A\x00\x00\x00\x65\x6E\x63\x6F\x64\x65\x72\x5F\x6F\x70\x74\x69\x6F\x6E\x73\x3D\x2D\x2D\x71\x75\x69\x65\x74\x20\x2D\x2D\x62\x69\x74\x72\x61\x74\x65\x20\x39\x36\x20\x2D\x2D\x76\x62\x72\x3B\x01\x00\x00\x70\x61\x64\x3D\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30"),
|
12
|
+
bytearray(
|
13
|
+
b"\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30\x30")
|
14
|
+
]
|