Version 3.4.0: Edit tab (edit list, large subtitle, replacements with effects and presets), Sounds tab only browses, ackit fx (pedalboard + own reverb) and replace --fx, first Linux build, catalog without a mod package

This commit is contained in:
2026-10-28 13:54:12 +01:00
parent a706dae419
commit 6de470174e
36 changed files with 2872 additions and 278 deletions

View File

@@ -1,14 +1,16 @@
"""Single-sound editor: listen, substitute (file or recording), process, apply / revert."""
"""Single-sound editor of the Edit tab: listen, replace (file or recording), process with effects (`effects`,
ackit/fx.py, between the trim and the loudness steps), apply / revert."""
import json
import os
import numpy as np
from PySide6.QtCore import Qt, QTimer, Signal
from PySide6.QtGui import QFont
from PySide6.QtWidgets import (QCheckBox, QComboBox, QDoubleSpinBox, QFileDialog, QGridLayout, QGroupBox, QSplitter,
QHBoxLayout, QLabel, QMessageBox, QProgressBar, QPushButton, QSlider,
from PySide6.QtWidgets import (QCheckBox, QComboBox, QDoubleSpinBox, QFileDialog, QGridLayout, QGroupBox,
QHBoxLayout, QLabel, QMessageBox, QProgressBar, QPushButton, QSizePolicy, QSlider,
QVBoxLayout, QWidget)
from ackit import audio, workspace as W
from ackit import audio, fx, workspace as W
from . import icons, theme
from .audioio import Recorder, input_devices, find_device
from .spinner import Spinner
@@ -29,6 +31,8 @@ class EditorPanel(QWidget):
applied = Signal(str) # sound key
reverted = Signal(str)
message = Signal(str)
source_changed = Signal() # a new replacement source (file, recording)
edited = Signal() # trim / gain / normalize / loudness changed
def __init__(self, ctx, parent=None):
super().__init__(parent)
@@ -39,6 +43,10 @@ class EditorPanel(QWidget):
self.src = None # replacement source (x, rate) before processing
self.src_name = ""
self.playing_what = None
self.effects = None # effects settings (ackit.fx) or None
self._src_gen = 0 # identity of the current source (effects cache key)
self._fx_cache = (None, None) # (key, rendered audio)
self._fx_pending = None
self.recorder = Recorder(self)
self._build()
self.set_sound(None)
@@ -66,14 +74,17 @@ class EditorPanel(QWidget):
def _wave(self, kind, selectable=False):
w = Waveform(selectable=selectable)
w.accent, w.bg = theme.WAVE[kind], theme.WAVE_BG
w.setMinimumHeight(44) # three rows + the rest fit the Edit tab without scrolling
w.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding) # and grow with the window
return w
def _build(self):
"""Header on top; below, side by side (resizable): LISTEN | REPLACE (with the apply buttons) | SUBTITLE."""
"""Top to bottom: header, SUBTITLE (when the sound has one), LISTEN (original, installed and replacement,
each with its play button), REPLACEMENT (record / load, trim, level), then the action bar."""
self.setObjectName("EditorPanel")
lay = QVBoxLayout(self)
lay.setContentsMargins(8, 6, 8, 6)
lay.setSpacing(4)
lay.setContentsMargins(8, 4, 8, 2)
lay.setSpacing(3)
self.title = QLabel()
self.title.setObjectName("EditorTitle")
self.title.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
@@ -93,25 +104,41 @@ class EditorPanel(QWidget):
self.meta.setObjectName("Muted")
lay.addWidget(self.meta)
# listen ----------------------------------------------------------------------------------
# the subtitle, large ------------------------------------------------------------------------
from .textpanel import SubtitlePanel
self.subtitle_box = QGroupBox("SUBTITLE")
sl = QVBoxLayout(self.subtitle_box)
sl.setContentsMargins(8, 8, 8, 8)
self.subtitle = SubtitlePanel(self.ctx, self.subtitle_box)
sl.addWidget(self.subtitle)
lay.addWidget(self.subtitle_box)
# listen: one row per version, its play button on the left -----------------------------------
g = QGroupBox("LISTEN")
gl = QGridLayout(g)
gl.setContentsMargins(8, 8, 8, 8)
gl.setVerticalSpacing(4)
gl.setHorizontalSpacing(8)
self.wf_orig = self._wave("orig")
self.wf_inst = self._wave("inst")
self.wf_new = self._wave("new", selectable=True)
self.wf_new.setToolTip("Click: play from here. Right-drag (or Shift+drag): select the part to keep.")
self.b_play_orig = self._btn("Original", "play", lambda: self.play("orig"),
"Play the original (double-click the row; Space until a replacement is loaded)",
icons.STEEL)
self.b_play_inst = self._btn("Installed", "play", lambda: self.play("inst"), "Play what the game has now", "#8f4a4e")
"Play the game's original (Space plays the replacement once there is one)", icons.STEEL)
self.b_play_inst = self._btn("Installed", "play", lambda: self.play("inst"), "Play what the game has now",
"#8f4a4e")
self.b_play_new = self._btn("Replacement", "play", lambda: self.play("new"),
"Play the replacement as it will be applied (trim, effects, level)", icons.ACCENT)
for i, (b, w) in enumerate(((self.b_play_orig, self.wf_orig), (self.b_play_inst, self.wf_inst),
(self.b_play_new, self.wf_new))):
b.setMinimumWidth(110)
gl.addWidget(b, i, 0)
gl.addWidget(w, i, 1)
self.b_stop = self._btn("Stop", "stop", self.stop, "Stop (Esc)")
# playback options next to the volume: icons with tooltips
self.b_ab = self._btn("", "ab", self.ab_switch, "A/B: switch between the original and the replacement at "
"the same position", icons.ACCENT)
self.cb_loop = self._btn("", "loop", None, "Loop: repeat playback")
self.b_ab = self._btn("A/B", "ab", self.ab_switch, "Switch between the original and the replacement at the same "
"position, to compare", icons.ACCENT)
self.cb_loop = self._btn("Loop", "loop", None, "Repeat playback")
self.cb_loop.setCheckable(True)
for b in (self.b_ab, self.cb_loop):
b.setFixedWidth(b.sizeHint().height() + 4)
self.sl_vol = QSlider(Qt.Orientation.Horizontal)
self.sl_vol.setRange(0, 100)
self.sl_vol.setValue(80)
@@ -122,71 +149,65 @@ class EditorPanel(QWidget):
self.lbl_time.setObjectName("Muted")
self.lbl_time.setAlignment(Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
self.lbl_time.setMinimumWidth(self.lbl_time.fontMetrics().horizontalAdvance("00:00.00") + 4)
gl.addWidget(QLabel("Original"), 0, 0)
gl.addWidget(self.wf_orig, 0, 1)
self.lbl_inst = QLabel("Installed")
gl.addWidget(self.lbl_inst, 1, 0)
gl.addWidget(self.wf_inst, 1, 1)
gl.setRowStretch(2, 1) # controls at the bottom, level with the other columns' actions
gl.addLayout(self._fill((self.b_play_orig, 1), (self.b_play_inst, 1), (self.b_stop, 1)), 4, 0, 1, 2)
vol = QHBoxLayout() # playback options: A/B, loop, volume, position
vol.setSpacing(6)
vol.addWidget(self.b_ab)
vol.addWidget(self.cb_loop)
vol.addSpacing(6)
vol.addWidget(QLabel("Volume"))
vol.addWidget(self.sl_vol, 1)
vol.addWidget(self.lbl_time)
gl.addLayout(vol, 3, 0, 1, 2)
tr = QHBoxLayout()
tr.setSpacing(6)
for w in (self.b_stop, self.b_ab, self.cb_loop):
tr.addWidget(w)
tr.addSpacing(10)
tr.addWidget(QLabel("Volume"))
tr.addWidget(self.sl_vol, 1)
tr.addWidget(self.lbl_time)
gl.addLayout(tr, 3, 0, 1, 2)
gl.setColumnStretch(1, 1)
self.wf_orig.seekRequested.connect(lambda t: self.play("orig", t))
self.wf_inst.seekRequested.connect(lambda t: self.play("inst", t))
listen = g
self.wf_new.seekRequested.connect(lambda t: self.play("new", t))
self.wf_new.selectionChanged.connect(self._selection)
for r in range(3):
gl.setRowStretch(r, 1) # the waveforms take the free height of the window
lay.addWidget(g, 1)
# replace ---------------------------------------------------------------------------------
g = QGroupBox("REPLACE")
# replacement: source, trim, level -----------------------------------------------------------
g = QGroupBox("REPLACEMENT")
rl = QVBoxLayout(g)
rl.setContentsMargins(8, 8, 8, 8)
rl.setSpacing(6)
src = QHBoxLayout()
src.setSpacing(6)
self.b_load = self._btn("Load file…", "open", self.load_file_dialog,
"WAV, FLAC, OGG, OPUS, MP3, AIFF (M4A/WMA need ffmpeg installed) (Ctrl+O)")
self.b_rec = self._btn("Record", "record", self.toggle_record, "Record from the selected input")
self.b_rec.setObjectName("Primary")
self.b_rec.setMinimumWidth(150)
self.cmb_in = QComboBox()
self.cmb_in.setMinimumWidth(110)
self.cmb_in.setToolTip("Recording device")
self.b_rec = self._btn("Record", "record", self.toggle_record, "Record from the selected input")
self.meter = QProgressBar()
self.meter.setRange(0, 100)
self.meter.setTextVisible(False)
self.meter.setFixedWidth(60)
self.meter.setFixedWidth(80)
self.meter.setToolTip("Input level")
self.lbl_rec = QLabel("")
self.lbl_rec.setObjectName("Muted")
src.addWidget(self.b_load, 1) # the row fills the column: file | microphone, level
self.b_load = self._btn("Load file…", "open", self.load_file_dialog,
"WAV, FLAC, OGG, OPUS, MP3, AIFF (M4A/WMA need ffmpeg installed) (Ctrl+O)")
src.addWidget(self.b_rec)
src.addWidget(self.cmb_in, 1)
src.addWidget(self.meter)
src.addWidget(self.lbl_rec)
src.addSpacing(10)
src.addWidget(self.cmb_in, 2)
src.addWidget(self.b_rec, 1)
for w in (self.meter, self.lbl_rec):
src.addWidget(w)
src.addWidget(self.b_load)
rl.addLayout(src)
self.wf_new = self._wave("new", selectable=True)
self.wf_new.setToolTip("Click: play from here. Right-drag (or Shift+drag): select the part to keep.")
self.wf_new.seekRequested.connect(lambda t: self.play("new", t))
self.wf_new.selectionChanged.connect(self._selection)
rl.addWidget(self.wf_new)
self.sp_a = QDoubleSpinBox()
self.sp_b = QDoubleSpinBox()
for s_ in (self.sp_a, self.sp_b):
s_.setDecimals(2)
s_.setSuffix(" s")
s_.setRange(0, 36000)
s_.setMaximumWidth(80)
s_.setMaximumWidth(90)
s_.valueChanged.connect(self._changed)
self.sp_gain = QDoubleSpinBox()
self.sp_gain.setRange(-40, 40)
self.sp_gain.setSuffix(" dB")
self.sp_gain.setMaximumWidth(80)
self.sp_gain.setMaximumWidth(90)
self.sp_gain.valueChanged.connect(self._changed)
self.cb_norm = QCheckBox("Normalize")
self.cb_match = QCheckBox("Match loudness")
@@ -194,17 +215,14 @@ class EditorPanel(QWidget):
self.cb_norm.toggled.connect(self._changed)
self.cb_match.toggled.connect(self._changed)
self.b_silence = self._btn("Trim silence", None, self.trim_silence, "Cut silence at the start and end")
self.b_play_new = self._btn("Replacement", "play", lambda: self.play("new"), "Play the processed replacement",
icons.ACCENT)
# edit controls in a grid: labels in one column, the fields aligned, playback on the right
proc = QGridLayout()
proc = QGridLayout() # labels in one column, the fields aligned
proc.setHorizontalSpacing(6)
proc.setVerticalSpacing(6)
proc.addWidget(QLabel("Keep"), 0, 0)
proc.addWidget(self.sp_a, 0, 1)
proc.addWidget(QLabel("to"), 0, 2, Qt.AlignmentFlag.AlignCenter)
proc.addWidget(self.sp_b, 0, 3)
proc.addLayout(self._fill((self.b_silence, 1), (self.b_play_new, 1)), 0, 4) # the rest of the width
proc.addWidget(self.b_silence, 0, 4)
proc.addWidget(QLabel("Gain"), 1, 0)
proc.addWidget(self.sp_gain, 1, 1)
checks = QHBoxLayout()
@@ -213,7 +231,7 @@ class EditorPanel(QWidget):
checks.addWidget(self.cb_match)
checks.addStretch(1)
proc.addLayout(checks, 1, 2, 1, 3)
proc.setColumnStretch(4, 1)
proc.setColumnStretch(5, 1)
rl.addLayout(proc)
info = QHBoxLayout()
self.lbl_new = QLabel()
@@ -229,37 +247,33 @@ class EditorPanel(QWidget):
info.addWidget(self.warn_icon, 0, Qt.AlignmentFlag.AlignTop)
info.addWidget(self.lbl_warn, 1)
rl.addLayout(info)
# apply (belongs to the replacement) ------------------------------------------------------
lay.addWidget(g)
# actions -----------------------------------------------------------------------------------
self.b_prev = self._btn("Previous", "prev", None, "Previous sound of the edit list (Alt+Left)")
self.b_next = self._btn("Next", "next", None, "Next sound of the edit list (Alt+Right)")
self.b_apply = self._btn("Apply to game", "apply", self.apply,
"Encode the replacement and install it in the game files (Ctrl+S)", "#ffffff")
self.b_apply.setObjectName("Primary")
self.b_revert = self._btn("Revert", "revert", self.revert, "Put the game's original back")
self.b_export = self._btn("Save WAV…", "save", self.export_wav,
"Save the original (or the replacement) as a WAV file")
"Save the original (or the replacement as applied) as a WAV file")
self.busy = Spinner(16)
self.lbl_busy = QLabel()
info.addWidget(self.busy, 0, Qt.AlignmentFlag.AlignTop) # encoding/writing: next to the status
info.addWidget(self.lbl_busy, 0, Qt.AlignmentFlag.AlignTop)
act = self._fill((self.b_apply, 2), (self.b_revert, 1), (self.b_export, 1))
rl.addStretch(1)
rl.addLayout(act)
replace = g
# subtitle of this sound (edited side by side with the audio) -------------------------------
from .textpanel import SubtitleBox
gs = QGroupBox("SUBTITLE")
gsl = QVBoxLayout(gs)
gsl.setContentsMargins(8, 8, 8, 8)
self.subtitle = SubtitleBox(self.ctx, gs)
gsl.addWidget(self.subtitle, 1)
self.columns = QSplitter(Qt.Orientation.Horizontal)
self.columns.setChildrenCollapsible(False)
self.columns.setHandleWidth(4)
for w, stretch in ((listen, 3), (replace, 4), (gs, 3)):
self.columns.addWidget(w)
self.columns.setStretchFactor(self.columns.count() - 1, stretch)
lay.addWidget(self.columns, 1)
self.action_bar = QWidget() # the Edit tab keeps it below its scroll area (always visible)
act = QHBoxLayout(self.action_bar)
act.setContentsMargins(8, 4, 8, 6)
act.setSpacing(6)
act.addWidget(self.b_prev, 1)
act.addSpacing(10)
act.addWidget(self.b_apply, 3)
act.addWidget(self.b_revert, 1)
act.addWidget(self.b_export, 1)
act.addWidget(self.busy)
act.addWidget(self.lbl_busy)
act.addSpacing(10)
act.addWidget(self.b_next, 1)
lay.addWidget(self.action_bar)
self.recorder.level.connect(lambda v: self.meter.setValue(int(min(1.0, v) * 100)))
self.recorder.elapsed.connect(lambda t: self.lbl_rec.setText("recording %s" % fmt_s(t)))
@@ -305,13 +319,16 @@ class EditorPanel(QWidget):
self.recorder.stop()
self.sound, self.orig, self.installed, self.src = s, None, None, None
enabled = s is not None
for w in self.findChildren(QPushButton) + [self.sp_a, self.sp_b, self.sp_gain, self.cb_norm, self.cb_match]:
# the action bar may live outside this widget (the Edit tab keeps it below its scroll area)
buttons = set(self.findChildren(QPushButton)) | set(self.action_bar.findChildren(QPushButton))
for w in list(buttons) + [self.sp_a, self.sp_b, self.sp_gain, self.cb_norm, self.cb_match]:
w.setEnabled(enabled)
self.wf_orig.set_audio(None, 1, "select a sound" if s is None else "loading…", busy=s is not None)
self.wf_inst.set_audio(None, 1, "")
self.wf_new.set_audio(None, 1, "load a file or record a replacement")
self.lbl_new.setText("")
self.subtitle.set_sound(s)
self.subtitle_box.setVisible(bool(self.subtitle.keys))
if s is None:
self.title.setText("No sound selected")
self.meta.setText("Search and select a sound in the list.")
@@ -380,8 +397,9 @@ class EditorPanel(QWidget):
self.ctx.runner.start(lambda job: audio.load(path), done, failed)
return result[0] if result else True
def set_source(self, x, sr, name):
def set_source(self, x, sr, name, restoring=False):
self.src, self.src_name = (np.asarray(x, np.float32), sr), name
self._src_gen += 1
dur = audio.duration(x, sr)
self.sp_a.blockSignals(True)
self.sp_b.blockSignals(True)
@@ -392,6 +410,8 @@ class EditorPanel(QWidget):
self.sp_a.blockSignals(False)
self.sp_b.blockSignals(False)
self._update_new()
if not restoring:
self.source_changed.emit()
def toggle_record(self):
if self.recorder.is_recording():
@@ -427,15 +447,65 @@ class EditorPanel(QWidget):
def _changed(self, *_):
self._reproc.start()
self.edited.emit()
def processed(self):
"""Replacement audio after trim / gain / normalize / loudness matching -> (x, rate) or None."""
if not self.src:
return None
def set_effects(self, settings):
self.effects = settings
self._changed()
def _fx_key(self):
return (self._src_gen, self.sp_a.value(), self.sp_b.value(), json.dumps(self.effects, sort_keys=True),
self.sound.channels if self.sound else None)
def _effected(self, render=True):
"""Trimmed replacement with the effects (cached). With render=False: None while not rendered yet."""
x, sr = self.src
a, b = self.sp_a.value(), self.sp_b.value()
if b > a:
x = audio.trim(x, sr, a, b)
if not fx.active(self.effects):
return x
key = self._fx_key()
if self._fx_cache[0] == key:
return self._fx_cache[1]
if not render:
return None
y = fx.render(x, sr, self.effects, key[-1])
self._fx_cache = (key, y)
return y
def _render_effects(self):
"""Render the effects in the background (spinner in the replacement waveform), then show the result."""
key = self._fx_key()
if self._fx_pending == key:
return
self._fx_pending = key
x, sr = self.src
a, b = self.sp_a.value(), self.sp_b.value()
if b > a:
x = audio.trim(x, sr, a, b)
settings = self.effects
self.wf_new.set_audio(None, 1, "applying effects…", busy=True)
def done(y):
if self._fx_pending == key:
self._fx_pending = None
self._fx_cache = (key, y)
if self.src and self._fx_key() == key:
self._update_new()
def failed(e, tb):
self._fx_pending = None
self._failed("The effects failed", e)
self.ctx.runner.start(lambda job: fx.render(x, sr, settings, key[-1]), done, failed)
def processed(self):
"""Replacement audio after trim / effects / gain / normalize / loudness matching -> (x, rate) or None."""
if not self.src:
return None
sr = self.src[1]
x = self._effected()
if self.cb_match.isChecked() and self.orig is not None:
x = audio.match_loudness(x, self.orig[0])
if self.cb_norm.isChecked():
@@ -445,6 +515,9 @@ class EditorPanel(QWidget):
return x, sr
def _update_new(self):
if self.src and self._effected(render=False) is None:
self._render_effects()
return
p = self.processed()
if p is None:
self.wf_new.set_audio(None, 1, "load a file or record a replacement")
@@ -455,7 +528,9 @@ class EditorPanel(QWidget):
self.wf_new.set_audio(x, sr)
dur = audio.duration(x, sr)
s = self.sound
self.lbl_new.setText("%s: %.2f s, peak %.1f dBFS" % (self.src_name, dur, audio.peak_db(x)))
on = fx.summary(self.effects)
self.lbl_new.setText("%s%s: %.2f s, peak %.1f dBFS" % (self.src_name, " + " + on if on else "", dur,
audio.peak_db(x)))
warnings = []
if s and not s.is_pack_media and dur > s.seconds * 1.15 + 0.25:
warnings.append("Longer than the original (%.2f s): it will be squeezed into the original's size "
@@ -477,6 +552,7 @@ class EditorPanel(QWidget):
if not a:
return
self.playing_what = what
self.ctx.player.owner = self # two editors share the player: only this one shows playheads
self.ctx.player.play(a[0], a[1], start, loop=self.cb_loop.isChecked(), label=what)
def toggle_play(self):
@@ -500,6 +576,8 @@ class EditorPanel(QWidget):
w.set_playhead(None)
def _pos(self, t):
if getattr(self.ctx.player, "owner", self) is not self:
return
self.lbl_time.setText(fmt_s(t))
wf = {"orig": self.wf_orig, "inst": self.wf_inst, "new": self.wf_new}.get(self.playing_what)
if wf:
@@ -535,6 +613,8 @@ class EditorPanel(QWidget):
return
ws = self.ctx.ws
meta = {"source": self.src_name}
if fx.active(self.effects):
meta["effects"] = fx.summary(self.effects)
def work(job):
job.report("encoding…")