Files
audio-wx/main.py

637 lines
24 KiB
Python

import os
import json
import threading
import shutil
import subprocess
import wx
import wx.media
AUDIO_FORMATS = ["mp3", "wav", "aac", "flac", "ogg", "m4a", "m4b"]
VIDEO_FORMATS = ["mp4", "mkv", "mov", "webm"]
class ConverterFrame(wx.Frame):
def __init__(self):
super().__init__(parent=None, title="Audio/Video Converter", size=(640, 380))
panel = wx.Panel(self)
label_color = wx.SystemSettings.GetColour(wx.SYS_COLOUR_WINDOWTEXT)
self.input_picker = wx.FilePickerCtrl(
panel,
message="Select input file",
style=wx.FLP_OPEN | wx.FLP_FILE_MUST_EXIST | wx.FLP_USE_TEXTCTRL,
)
self.mode_choice = wx.Choice(panel, choices=["Audio", "Video"])
self.mode_choice.SetSelection(0)
self.format_choice = wx.Choice(panel, choices=AUDIO_FORMATS)
self.format_choice.SetSelection(0)
self.output_picker = wx.FilePickerCtrl(
panel,
message="Select output file",
style=wx.FLP_SAVE | wx.FLP_OVERWRITE_PROMPT | wx.FLP_USE_TEXTCTRL,
)
self.audio_bitrate = wx.TextCtrl(panel, value="", size=(120, -1))
self.video_bitrate = wx.TextCtrl(panel, value="", size=(120, -1))
self.activation_bytes = wx.TextCtrl(panel, value="", size=(120, -1))
self.copy_streams = wx.CheckBox(panel, label="Co&py streams (fast, no re-encode)")
self.copy_streams.SetForegroundColour(label_color)
self.split_mp3_choice = wx.Choice(panel, choices=["No", "Yes"])
self.split_mp3_choice.SetSelection(0)
self.start_btn = wx.Button(panel, label="&Convert")
self.log_ctrl = wx.TextCtrl(panel, style=wx.TE_MULTILINE | wx.TE_READONLY)
self.status = wx.StaticText(panel, label="Ready")
self.status.SetForegroundColour(label_color)
self.progress_gauge = wx.Gauge(panel, range=1000, style=wx.GA_HORIZONTAL)
self.progress_gauge.SetValue(0)
self.progress_label = wx.StaticText(panel, label="Progress")
self.progress_label.SetForegroundColour(label_color)
self.media = wx.media.MediaCtrl(panel, style=wx.SIMPLE_BORDER)
self.play_btn = wx.Button(panel, label="&Play/Pause")
self.stop_btn = wx.Button(panel, label="S&top")
self.position_slider = wx.Slider(panel, minValue=0, maxValue=1000, style=wx.SL_HORIZONTAL)
self.time_label = wx.StaticText(panel, label="00:00 / 00:00")
self.time_label.SetForegroundColour(label_color)
self.media_length_ms = 0
self.dragging_slider = False
self.pending_play = False
self.media_loaded = False
self.loaded_media_path = ""
self.player_timer = wx.Timer(self)
self.audio_bitrate.SetHint("e.g. 192k")
self.video_bitrate.SetHint("e.g. 2000k")
self.activation_bytes.SetHint("e.g. 1")
self.audio_bitrate.SetName("Audio bitrate")
self.video_bitrate.SetName("Video bitrate")
self.activation_bytes.SetName("Activation bytes")
self.configure_file_picker_accessibility(self.input_picker, "Input file")
self.mode_choice.SetName("Conversion mode")
self.format_choice.SetName("Output format")
self.configure_file_picker_accessibility(self.output_picker, "Output file")
self.copy_streams.SetName("Copy streams")
self.split_mp3_choice.SetName("Split MP3 into captures")
self.start_btn.SetName("Convert")
self.play_btn.SetName("Play or pause")
self.stop_btn.SetName("Stop playback")
self.input_picker.SetHelpText("Input file")
self.mode_choice.SetHelpText("Conversion mode")
self.format_choice.SetHelpText("Output format")
self.output_picker.SetHelpText("Output file")
self.audio_bitrate.SetHelpText("Audio bitrate")
self.video_bitrate.SetHelpText("Video bitrate")
self.activation_bytes.SetHelpText("Activation bytes")
self.copy_streams.SetHelpText("Copy streams")
self.split_mp3_choice.SetHelpText("Split MP3 output into captures based on chapters")
self.start_btn.SetHelpText("Convert")
self.play_btn.SetHelpText("Play or pause")
self.stop_btn.SetHelpText("Stop playback")
self.media.SetMinSize((200, 36))
if hasattr(self.media, "SetVolume"):
self.media.SetVolume(1.0)
form = wx.BoxSizer(wx.VERTICAL)
self.add_labeled_control(panel, form, "&Input file", self.input_picker, "Input file")
self.add_labeled_control(panel, form, "&Mode", self.mode_choice, "Conversion mode")
self.add_labeled_control(panel, form, "F&ormat", self.format_choice, "Output format")
self.add_labeled_control(panel, form, "O&utput file", self.output_picker, "Output file")
self.add_labeled_control(panel, form, "Audio &bitrate", self.audio_bitrate, "Audio bitrate")
self.add_labeled_control(panel, form, "Video b&itrate", self.video_bitrate, "Video bitrate")
self.add_labeled_control(panel, form, "A&ctivation bytes", self.activation_bytes, "Activation bytes")
form.Add(self.copy_streams, 0, wx.EXPAND | wx.BOTTOM, 6)
self.add_labeled_control(panel, form, "Split MP3 into &captures", self.split_mp3_choice, "Split MP3 into captures")
main = wx.BoxSizer(wx.VERTICAL)
main.Add(form, 0, wx.EXPAND | wx.ALL, 10)
main.Add(self.start_btn, 0, wx.LEFT | wx.RIGHT | wx.BOTTOM, 10)
main.Add(self.build_player(panel), 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 10)
main.Add(self.progress_label, 0, wx.LEFT | wx.RIGHT | wx.BOTTOM, 4)
main.Add(self.progress_gauge, 0, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 10)
main.Add(self.log_ctrl, 1, wx.EXPAND | wx.LEFT | wx.RIGHT, 10)
main.Add(self.status, 0, wx.LEFT | wx.RIGHT | wx.BOTTOM, 10)
panel.SetSizer(main)
self.mode_choice.Bind(wx.EVT_CHOICE, self.on_mode_change)
self.format_choice.Bind(wx.EVT_CHOICE, self.on_format_change)
self.input_picker.Bind(wx.EVT_FILEPICKER_CHANGED, self.on_input_change)
self.start_btn.Bind(wx.EVT_BUTTON, self.on_start)
self.play_btn.Bind(wx.EVT_BUTTON, self.on_play_pause)
self.stop_btn.Bind(wx.EVT_BUTTON, self.on_stop)
self.position_slider.Bind(wx.EVT_SLIDER, self.on_seek)
self.position_slider.Bind(wx.EVT_SCROLL_THUMBRELEASE, self.on_seek_release)
self.Bind(wx.media.EVT_MEDIA_LOADED, self.on_media_loaded, self.media)
self.Bind(wx.media.EVT_MEDIA_FINISHED, self.on_media_finished, self.media)
self.Bind(wx.EVT_TIMER, self.on_player_timer, self.player_timer)
self.setup_hotkeys()
self.update_format_choices()
self.Center()
self.player_timer.Start(250)
def on_mode_change(self, _event):
self.update_format_choices()
self.suggest_output_path()
def on_format_change(self, _event):
self.suggest_output_path()
def on_input_change(self, _event):
self.suggest_output_path()
self.load_media_from_input()
def update_format_choices(self):
mode = self.mode_choice.GetStringSelection()
if mode == "Audio":
choices = AUDIO_FORMATS
else:
choices = VIDEO_FORMATS
self.format_choice.Set(choices)
self.format_choice.SetSelection(0)
self.update_split_option_state()
def suggest_output_path(self):
in_path = self.input_picker.GetPath()
if not in_path:
return
out_ext = self.format_choice.GetStringSelection()
base, _ = os.path.splitext(in_path)
suggested = base + "." + out_ext
self.output_picker.SetPath(suggested)
self.update_split_option_state()
def update_split_option_state(self):
is_mp3 = self.format_choice.GetStringSelection().lower() == "mp3"
self.split_mp3_choice.Enable(is_mp3)
if not is_mp3:
self.split_mp3_choice.SetSelection(0)
def build_player(self, panel):
player = wx.BoxSizer(wx.VERTICAL)
controls = wx.BoxSizer(wx.HORIZONTAL)
controls.Add(self.play_btn, 0, wx.RIGHT, 8)
controls.Add(self.stop_btn, 0, wx.RIGHT, 8)
controls.Add(self.time_label, 0, wx.ALIGN_CENTER_VERTICAL)
player.Add(self.media, 0, wx.EXPAND | wx.BOTTOM, 6)
player.Add(self.position_slider, 0, wx.EXPAND | wx.BOTTOM, 6)
player.Add(controls, 0, wx.EXPAND | wx.BOTTOM, 4)
return player
def add_labeled_control(self, panel, parent_sizer, label, control, accessible_name=None):
label_ctrl = wx.StaticText(panel, label=label)
label_ctrl.SetForegroundColour(wx.SystemSettings.GetColour(wx.SYS_COLOUR_WINDOWTEXT))
clean_label = label.replace("&", "")
if accessible_name is None:
accessible_name = clean_label
# Keep a stable, human-readable name/description for assistive technologies.
control.SetName(accessible_name)
control.SetHelpText(accessible_name)
label_ctrl.SetName(clean_label)
# Keep label and field adjacent in tab order for native AT label lookup.
if hasattr(label_ctrl, "MoveBeforeInTabOrder"):
label_ctrl.MoveBeforeInTabOrder(control)
field_sizer = wx.BoxSizer(wx.VERTICAL)
field_sizer.Add(label_ctrl, 0, wx.BOTTOM, 2)
field_sizer.Add(control, 0, wx.EXPAND)
parent_sizer.Add(field_sizer, 0, wx.EXPAND | wx.BOTTOM, 6)
def configure_file_picker_accessibility(self, picker, label):
picker.SetName(label)
picker.SetHelpText(label)
text_ctrl = picker.GetTextCtrl()
if text_ctrl:
text_ctrl.SetName(f"{label} path")
text_ctrl.SetHelpText(label)
button = picker.GetPickerCtrl()
if button:
button.SetName(f"Browse {label}")
button.SetHelpText(f"Browse {label}")
def setup_hotkeys(self):
self.ID_PLAYPAUSE = wx.NewIdRef()
self.ID_STOP = wx.NewIdRef()
self.ID_CONVERT = wx.NewIdRef()
self.ID_SEEK_BACK = wx.NewIdRef()
self.ID_SEEK_FWD = wx.NewIdRef()
entries = [
wx.AcceleratorEntry(wx.ACCEL_NORMAL, wx.WXK_SPACE, self.ID_PLAYPAUSE),
wx.AcceleratorEntry(wx.ACCEL_CTRL, ord("S"), self.ID_STOP),
wx.AcceleratorEntry(wx.ACCEL_CTRL, wx.WXK_RETURN, self.ID_CONVERT),
wx.AcceleratorEntry(wx.ACCEL_ALT, wx.WXK_LEFT, self.ID_SEEK_BACK),
wx.AcceleratorEntry(wx.ACCEL_ALT, wx.WXK_RIGHT, self.ID_SEEK_FWD),
]
self.SetAcceleratorTable(wx.AcceleratorTable(entries))
self.Bind(wx.EVT_MENU, self.on_play_pause, id=self.ID_PLAYPAUSE)
self.Bind(wx.EVT_MENU, self.on_stop, id=self.ID_STOP)
self.Bind(wx.EVT_MENU, self.on_start, id=self.ID_CONVERT)
self.Bind(wx.EVT_MENU, self.on_seek_back, id=self.ID_SEEK_BACK)
self.Bind(wx.EVT_MENU, self.on_seek_forward, id=self.ID_SEEK_FWD)
def load_media_from_input(self):
path = self.input_picker.GetPath()
if not path:
return
self.load_media(path)
def load_media(self, path):
if not os.path.isfile(path):
return
self.media_length_ms = 0
self.media_loaded = False
self.loaded_media_path = path
self.position_slider.SetRange(0, 1000)
self.position_slider.SetValue(0)
self.update_time_label(0)
if not self.media.Load(path):
self.pending_play = False
self.append_log("Unable to load media: " + path)
else:
self.media_loaded = True
def on_media_loaded(self, _event):
self.media_loaded = True
length = self.media.Length()
if length and length > 0:
self.media_length_ms = length
self.position_slider.SetRange(0, length)
self.update_time_label(0)
if self.pending_play:
self.pending_play = False
self.media.Play()
def on_media_finished(self, _event):
self.update_time_label(self.media_length_ms)
if self.media_length_ms > 0:
self.position_slider.SetValue(self.media_length_ms)
def on_player_timer(self, _event):
if self.dragging_slider or self.media_length_ms <= 0:
return
if self.media.GetState() == wx.media.MEDIASTATE_PLAYING:
position = self.media.Tell()
if position >= 0:
self.position_slider.SetValue(position)
self.update_time_label(position)
def on_play_pause(self, _event):
path = self.input_picker.GetPath()
if not path:
return
state = self.media.GetState()
if state == wx.media.MEDIASTATE_PLAYING:
self.pending_play = False
self.media.Pause()
return
if (not self.media_loaded) or (self.loaded_media_path != path):
self.load_media(path)
if self.media.Play():
self.pending_play = False
else:
self.pending_play = True
def on_stop(self, _event):
self.pending_play = False
self.media.Stop()
self.position_slider.SetValue(0)
self.update_time_label(0)
def on_seek(self, _event):
self.dragging_slider = True
if self.media_length_ms <= 0:
return
self.update_time_label(self.position_slider.GetValue())
def on_seek_release(self, _event):
self.dragging_slider = False
if self.media_length_ms <= 0:
return
self.media.Seek(self.position_slider.GetValue())
def on_seek_back(self, _event):
self.seek_relative(-5000)
def on_seek_forward(self, _event):
self.seek_relative(5000)
def seek_relative(self, delta_ms):
if self.media_length_ms <= 0:
return
position = self.media.Tell()
if position < 0:
position = 0
new_pos = max(0, min(self.media_length_ms, position + delta_ms))
self.media.Seek(new_pos)
self.position_slider.SetValue(new_pos)
self.update_time_label(new_pos)
def update_time_label(self, position_ms):
total = self.media_length_ms
self.time_label.SetLabel(
f"{self.format_time(position_ms)} / {self.format_time(total)}"
)
def format_time(self, millis):
seconds = max(0, int(millis / 1000))
minutes = seconds // 60
seconds = seconds % 60
return f"{minutes:02d}:{seconds:02d}"
def on_start(self, _event):
if not shutil.which("ffmpeg"):
wx.MessageBox("ffmpeg not found on PATH.", "Error", wx.ICON_ERROR)
return
in_path = self.input_picker.GetPath()
out_path = self.output_picker.GetPath()
if not in_path or not out_path:
wx.MessageBox("Please choose input and output files.", "Error", wx.ICON_ERROR)
return
self.log_ctrl.Clear()
self.status.SetLabel("Running...")
self.start_btn.Disable()
self.reset_progress()
thread = threading.Thread(target=self.run_ffmpeg, args=(in_path, out_path), daemon=True)
thread.start()
def run_ffmpeg(self, in_path, out_path):
mode = self.mode_choice.GetStringSelection()
a_bitrate = self.audio_bitrate.GetValue().strip()
v_bitrate = self.video_bitrate.GetValue().strip()
activation_bytes = self.activation_bytes.GetValue().strip()
copy = self.copy_streams.GetValue()
split_mp3 = self.split_mp3_choice.GetStringSelection().lower() == "yes"
output_format = self.format_choice.GetStringSelection().lower()
should_split_mp3 = split_mp3 and output_format == "mp3"
duration = self.get_media_duration(in_path)
self.prepare_progress(duration)
cmd = ["ffmpeg", "-y"]
cmd += ["-progress", "pipe:1", "-nostats"]
if activation_bytes:
cmd += ["-activation_bytes", activation_bytes]
aaxc_key, aaxc_iv = self.get_aaxc_key_iv(in_path)
if aaxc_key and aaxc_iv:
cmd += ["-audible_key", aaxc_key, "-audible_iv", aaxc_iv]
elif os.path.splitext(in_path)[1].lower() == ".aaxc":
self.append_log("Missing or invalid .voucher key/iv for .aaxc file.")
self.finish("Failed")
return
cmd += ["-i", in_path]
if mode == "Audio":
cmd.append("-vn")
if copy:
cmd += ["-c:a", "copy"]
elif a_bitrate:
cmd += ["-b:a", a_bitrate]
else:
if copy:
cmd += ["-c:v", "copy", "-c:a", "copy"]
else:
if v_bitrate:
cmd += ["-b:v", v_bitrate]
if a_bitrate:
cmd += ["-b:a", a_bitrate]
if should_split_mp3:
chapter_times = self.get_chapter_end_times(in_path)
if chapter_times:
output_pattern = self.build_capture_output_pattern(out_path)
cmd += [
"-f",
"segment",
"-segment_times",
",".join(chapter_times),
"-reset_timestamps",
"1",
"-segment_format",
"mp3",
output_pattern,
]
else:
self.append_log("No chapter markers found; writing a single MP3 file.")
cmd.append(out_path)
else:
cmd.append(out_path)
self.append_log("Command: " + " ".join(cmd))
try:
proc = subprocess.Popen(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
universal_newlines=True,
)
for line in proc.stdout:
line = line.rstrip()
if self.handle_progress_line(line, duration):
continue
if line:
self.append_log(line)
proc.wait()
if proc.returncode == 0:
self.set_progress_complete()
self.finish("Done")
else:
self.finish("Failed")
except Exception as exc:
self.append_log(f"Error: {exc}")
self.finish("Failed")
def append_log(self, text):
wx.CallAfter(self.log_ctrl.AppendText, text + "\n")
def finish(self, status_text):
wx.CallAfter(self.status.SetLabel, status_text)
wx.CallAfter(self.start_btn.Enable)
def reset_progress(self):
self.progress_gauge.SetRange(1000)
self.progress_gauge.SetValue(0)
def prepare_progress(self, duration_seconds):
if duration_seconds and duration_seconds > 0:
wx.CallAfter(self.progress_gauge.SetRange, 1000)
wx.CallAfter(self.progress_gauge.SetValue, 0)
else:
wx.CallAfter(self.progress_gauge.Pulse)
def set_progress_value(self, ratio):
ratio = max(0.0, min(1.0, ratio))
value = int(ratio * 1000)
wx.CallAfter(self.progress_gauge.SetValue, value)
def set_progress_complete(self):
wx.CallAfter(self.progress_gauge.SetValue, 1000)
def handle_progress_line(self, line, duration_seconds):
if not line:
return False
if line.startswith("out_time_ms="):
if duration_seconds and duration_seconds > 0:
try:
out_ms = int(line.split("=", 1)[1].strip())
ratio = out_ms / (duration_seconds * 1_000_000)
self.set_progress_value(ratio)
except (ValueError, ZeroDivisionError):
pass
else:
wx.CallAfter(self.progress_gauge.Pulse)
return True
if line.startswith("progress="):
if line.split("=", 1)[1].strip() == "end":
self.set_progress_complete()
return True
return False
def get_media_duration(self, in_path):
if not shutil.which("ffprobe"):
return 0
cmd = [
"ffprobe",
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"default=nk=1:nw=1",
in_path,
]
try:
proc = subprocess.run(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
check=False,
)
if proc.returncode != 0:
return 0
value = (proc.stdout or "").strip()
return float(value) if value else 0
except Exception:
return 0
def get_aaxc_key_iv(self, in_path):
if os.path.splitext(in_path)[1].lower() != ".aaxc":
return None, None
voucher_path = os.path.splitext(in_path)[0] + ".voucher"
if not os.path.isfile(voucher_path):
return None, None
try:
with open(voucher_path, "r", encoding="utf-8") as handle:
data = json.load(handle)
except Exception:
return None, None
if not isinstance(data, dict):
return None, None
# Support multiple voucher shapes:
# - content_license.license_response (current Audible export shape)
# - lilicense_response (legacy/typo compatibility)
# - license_response (flat shape fallback)
license_resp = None
content_license = data.get("content_license")
if isinstance(content_license, dict):
candidate = content_license.get("license_response")
if isinstance(candidate, dict):
license_resp = candidate
if license_resp is None:
candidate = data.get("lilicense_response")
if isinstance(candidate, dict):
license_resp = candidate
if license_resp is None:
candidate = data.get("license_response")
if isinstance(candidate, dict):
license_resp = candidate
if not isinstance(license_resp, dict):
return None, None
key = license_resp.get("key")
iv = license_resp.get("iv")
if not key or not iv:
return None, None
return str(key), str(iv)
def build_capture_output_pattern(self, out_path):
base, ext = os.path.splitext(out_path)
return f"{base}_capture_%03d{ext}"
def get_chapter_end_times(self, in_path):
if not shutil.which("ffprobe"):
self.append_log("ffprobe not found on PATH; cannot split by chapters.")
return []
cmd = [
"ffprobe",
"-v",
"error",
"-print_format",
"json",
"-show_chapters",
"-i",
in_path,
]
try:
proc = subprocess.run(
cmd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
check=False,
)
if proc.returncode != 0:
self.append_log("ffprobe failed; cannot split by chapters.")
return []
data = json.loads(proc.stdout or "{}")
except Exception as exc:
self.append_log(f"ffprobe error: {exc}")
return []
chapters = data.get("chapters", [])
end_times = []
for chapter in chapters:
end_time = chapter.get("end_time")
if end_time is None:
continue
try:
end_times.append(float(end_time))
except (TypeError, ValueError):
continue
if len(end_times) <= 1:
return []
end_times = end_times[:-1]
formatted = []
for value in end_times:
formatted.append(f"{value:.3f}".rstrip("0").rstrip("."))
return formatted
class App(wx.App):
def OnInit(self):
frame = ConverterFrame()
frame.Show()
return True
if __name__ == "__main__":
app = App()
app.MainLoop()