671 lines
29 KiB
Python
671 lines
29 KiB
Python
"""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,
|
|
QHBoxLayout, QLabel, QMessageBox, QProgressBar, QPushButton, QSizePolicy, QSlider,
|
|
QVBoxLayout, QWidget)
|
|
|
|
from ackit import audio, fx, workspace as W
|
|
from . import icons, theme
|
|
from .audioio import Recorder, input_devices, find_device
|
|
from .spinner import Spinner
|
|
from .waveform import Waveform
|
|
|
|
AUDIO_FILTER = "Audio files (*.wav *.flac *.ogg *.oga *.opus *.mp3 *.aif *.aiff *.m4a *.aac *.wma);;All files (*)"
|
|
STATE_TEXT = {W.ORIGINAL: "Original", W.MODIFIED: "Modified (your replacement is installed)",
|
|
W.LOST: "Your change was undone outside the tool (e.g. Steam 'verify files')",
|
|
W.CHANGED: "Changed outside this tool (Steam update or another mod tool)",
|
|
W.FOREIGN: "Differs from the original game file (modified by another tool?)"}
|
|
|
|
|
|
def fmt_s(t):
|
|
return "%d:%05.2f" % (int(t // 60), t % 60)
|
|
|
|
|
|
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)
|
|
self.ctx = ctx # main window: .ws, .runner, .player, .settings
|
|
self.sound = None
|
|
self.orig = None # (x, rate)
|
|
self.installed = None
|
|
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)
|
|
|
|
# --- UI --------------------------------------------------------------------------------------
|
|
def _btn(self, text, icon=None, slot=None, tip=None, color=None):
|
|
b = QPushButton(text)
|
|
if icon is not None:
|
|
b.setIcon(icons.icon(icon, color or icons.TEXT))
|
|
if slot:
|
|
b.clicked.connect(slot)
|
|
if tip:
|
|
b.setToolTip(tip)
|
|
return b
|
|
|
|
@staticmethod
|
|
def _fill(*items):
|
|
"""A row of buttons filling the column's width: [(widget, share)], equal edges in every column."""
|
|
row = QHBoxLayout()
|
|
row.setSpacing(6)
|
|
for w, share in items:
|
|
row.addWidget(w, share)
|
|
return row
|
|
|
|
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):
|
|
"""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, 4, 8, 2)
|
|
lay.setSpacing(3)
|
|
self.title = QLabel()
|
|
self.title.setObjectName("EditorTitle")
|
|
self.title.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
|
|
f = self.title.font()
|
|
f.setPointSizeF(f.pointSizeF() * 1.35)
|
|
f.setBold(True)
|
|
f.setLetterSpacing(QFont.SpacingType.PercentageSpacing, 104)
|
|
self.title.setFont(f)
|
|
self.state = QLabel()
|
|
self.state.setWordWrap(True)
|
|
head = QHBoxLayout()
|
|
head.addWidget(self.title, 1)
|
|
head.addWidget(self.state, 0, Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter)
|
|
lay.addLayout(head)
|
|
self.meta = QLabel()
|
|
self.meta.setWordWrap(True)
|
|
self.meta.setObjectName("Muted")
|
|
lay.addWidget(self.meta)
|
|
|
|
# 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 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)")
|
|
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)
|
|
self.sl_vol = QSlider(Qt.Orientation.Horizontal)
|
|
self.sl_vol.setRange(0, 100)
|
|
self.sl_vol.setValue(80)
|
|
self.sl_vol.setMinimumWidth(80)
|
|
self.sl_vol.setToolTip("Volume")
|
|
self.sl_vol.valueChanged.connect(lambda v: self.ctx.player.set_volume(v / 100.0))
|
|
self.lbl_time = QLabel("0:00.00")
|
|
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)
|
|
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))
|
|
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)
|
|
|
|
# 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_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.meter = QProgressBar()
|
|
self.meter.setRange(0, 100)
|
|
self.meter.setTextVisible(False)
|
|
self.meter.setFixedWidth(80)
|
|
self.meter.setToolTip("Input level")
|
|
self.lbl_rec = QLabel("")
|
|
self.lbl_rec.setObjectName("Muted")
|
|
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.b_load)
|
|
rl.addLayout(src)
|
|
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(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(90)
|
|
self.sp_gain.valueChanged.connect(self._changed)
|
|
self.cb_norm = QCheckBox("Normalize")
|
|
self.cb_match = QCheckBox("Match loudness")
|
|
self.cb_match.setToolTip("Match the loudness of the original sound")
|
|
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")
|
|
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.addWidget(self.b_silence, 0, 4)
|
|
proc.addWidget(QLabel("Gain"), 1, 0)
|
|
proc.addWidget(self.sp_gain, 1, 1)
|
|
checks = QHBoxLayout()
|
|
checks.setSpacing(12)
|
|
checks.addWidget(self.cb_norm)
|
|
checks.addWidget(self.cb_match)
|
|
checks.addStretch(1)
|
|
proc.addLayout(checks, 1, 2, 1, 3)
|
|
proc.setColumnStretch(5, 1)
|
|
rl.addLayout(proc)
|
|
info = QHBoxLayout()
|
|
self.lbl_new = QLabel()
|
|
self.lbl_new.setObjectName("Muted")
|
|
self.lbl_new.setWordWrap(True)
|
|
self.warn_icon = QLabel()
|
|
self.warn_icon.setPixmap(icons.icon("warning", icons.ACCENT).pixmap(18, 18))
|
|
self.warn_icon.hide()
|
|
self.lbl_warn = QLabel()
|
|
self.lbl_warn.setWordWrap(True)
|
|
self.lbl_warn.setStyleSheet("color: %s;" % icons.ACCENT)
|
|
info.addWidget(self.lbl_new, 1, Qt.AlignmentFlag.AlignTop)
|
|
info.addWidget(self.warn_icon, 0, Qt.AlignmentFlag.AlignTop)
|
|
info.addWidget(self.lbl_warn, 1)
|
|
rl.addLayout(info)
|
|
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 applied) as a WAV file")
|
|
self.busy = Spinner(16)
|
|
self.lbl_busy = QLabel()
|
|
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)))
|
|
self.recorder.error.connect(lambda m: self.inform("Recording", m))
|
|
self.ctx.player.position.connect(self._pos)
|
|
self.ctx.player.playing.connect(lambda on: None if on else self._clear_playheads())
|
|
self._reproc = QTimer(self)
|
|
self._reproc.setSingleShot(True)
|
|
self._reproc.setInterval(150)
|
|
self._reproc.timeout.connect(self._update_new)
|
|
|
|
def refresh_devices(self):
|
|
cur = self.ctx.settings.get("input_device")
|
|
self.cmb_in.clear()
|
|
self.cmb_in.addItem("Default input")
|
|
for d in input_devices():
|
|
self.cmb_in.addItem(d.description())
|
|
if cur:
|
|
i = self.cmb_in.findText(cur)
|
|
if i >= 0:
|
|
self.cmb_in.setCurrentIndex(i)
|
|
|
|
# --- dialogs (overridable in tests) -----------------------------------------------------------
|
|
def confirm(self, title, text):
|
|
return QMessageBox.question(self, title, text) == QMessageBox.StandardButton.Yes
|
|
|
|
def inform(self, title, text):
|
|
QMessageBox.information(self, title, text)
|
|
|
|
def ask_open(self):
|
|
p, _ = QFileDialog.getOpenFileName(self, "Choose replacement audio", self.ctx.settings.get("last_dir") or "",
|
|
AUDIO_FILTER)
|
|
return p
|
|
|
|
def ask_save(self, default):
|
|
p, _ = QFileDialog.getSaveFileName(self, "Save as WAV", default, "WAV (*.wav)")
|
|
return p
|
|
|
|
# --- loading ---------------------------------------------------------------------------------
|
|
def set_sound(self, s):
|
|
self.stop()
|
|
if self.recorder.is_recording():
|
|
self.recorder.stop()
|
|
self.sound, self.orig, self.installed, self.src = s, None, None, None
|
|
enabled = s is not None
|
|
# 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.")
|
|
self.state.setText("")
|
|
return
|
|
self.title.setText(s.name)
|
|
who = s.character or (("mentions " + s.characters_inferred) if s.characters_inferred else "—")
|
|
lim = self.ctx.ws.limit(s)
|
|
where = ""
|
|
if s.bank:
|
|
n = len(self.ctx.ws.locations(s))
|
|
where = " • bank %s (%d %s)" % (s.bank, n, "copy" if n == 1 else "copies")
|
|
self.meta.setText("%s / %s • %s • %.2f s, %d Hz, %s • max size: %s • %s%s"
|
|
% (s.group, s.category, who, s.seconds, s.rate,
|
|
{1: "mono", 2: "stereo"}.get(s.channels, "%d ch" % s.channels),
|
|
"any" if lim is None else "%.1f KB" % (lim / 1024.0), s.key, where))
|
|
key = s.key
|
|
ws = self.ctx.ws
|
|
|
|
def work(job):
|
|
cur = ws.current_bytes(s)
|
|
st = ws.state(s, cur)
|
|
o = audio.decode_wem(ws.original_bytes(s))
|
|
inst = audio.decode_wem(cur) if st != W.ORIGINAL else None
|
|
return key, st, o, inst
|
|
|
|
self.ctx.runner.start(work, self._loaded, lambda e, tb: self._failed("Loading the sound failed", e))
|
|
|
|
def _loaded(self, r):
|
|
key, st, o, inst = r
|
|
if not self.sound or self.sound.key != key:
|
|
return
|
|
self.orig, self.installed = o, inst
|
|
self.wf_orig.set_audio(*o)
|
|
self.wf_inst.set_audio(*(inst or (None, 1)), placeholder="same as original")
|
|
self.b_play_inst.setEnabled(inst is not None)
|
|
self.b_revert.setEnabled(st != W.ORIGINAL or bool(self.ctx.ws.store.get(key)))
|
|
self.state.setText("State: " + STATE_TEXT.get(st, st))
|
|
self._changed()
|
|
|
|
# --- replacement source ----------------------------------------------------------------------
|
|
def load_file_dialog(self):
|
|
p = self.ask_open()
|
|
if p:
|
|
self.ctx.settings["last_dir"] = os.path.dirname(p)
|
|
self.load_file(p)
|
|
|
|
def load_file(self, path):
|
|
"""Decode in the background (spinner in the replacement waveform); False if it already failed."""
|
|
name = os.path.basename(path)
|
|
self.wf_new.set_audio(None, 1, "loading %s…" % name, busy=True)
|
|
result = []
|
|
|
|
def done(r):
|
|
result.append(True)
|
|
self.set_source(r[0], r[1], name)
|
|
|
|
def failed(e, tb):
|
|
result.append(False)
|
|
self.wf_new.set_audio(None, 1, "load a file or record a replacement")
|
|
if isinstance(e, audio.AudioError):
|
|
self.inform("Unsupported audio", str(e))
|
|
else:
|
|
self._failed("Loading the file failed", e)
|
|
|
|
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, 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)
|
|
self.sp_a.setMaximum(dur)
|
|
self.sp_b.setMaximum(dur)
|
|
self.sp_a.setValue(0)
|
|
self.sp_b.setValue(dur)
|
|
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():
|
|
x, sr = self.recorder.stop()
|
|
self.b_rec.setText("Record")
|
|
self.b_rec.setIcon(icons.icon("record"))
|
|
self.meter.setValue(0)
|
|
if len(x) < sr // 10:
|
|
self.lbl_rec.setText("nothing recorded")
|
|
return
|
|
self.lbl_rec.setText("recorded %s" % fmt_s(len(x) / sr))
|
|
self.set_source(x, sr, "recording")
|
|
return
|
|
name = self.cmb_in.currentText()
|
|
self.recorder.set_device(find_device(input_devices(), name) if self.cmb_in.currentIndex() > 0 else None)
|
|
self.ctx.settings["input_device"] = name if self.cmb_in.currentIndex() > 0 else None
|
|
if self.recorder.start():
|
|
self.stop()
|
|
self.b_rec.setText("Stop recording")
|
|
self.b_rec.setIcon(icons.icon("stop", icons.DANGER))
|
|
|
|
def _selection(self, a, b):
|
|
if b > a:
|
|
self.sp_a.setValue(a)
|
|
self.sp_b.setValue(b)
|
|
|
|
def trim_silence(self):
|
|
if not self.src:
|
|
return
|
|
x, sr = self.src
|
|
y = audio.trim_silence(x, rate=sr)
|
|
self.set_source(y, sr, self.src_name)
|
|
|
|
def _changed(self, *_):
|
|
self._reproc.start()
|
|
self.edited.emit()
|
|
|
|
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():
|
|
x = audio.normalize(x)
|
|
if self.sp_gain.value():
|
|
x = audio.gain(x, self.sp_gain.value())
|
|
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")
|
|
self.lbl_new.setText("")
|
|
self._warn([])
|
|
return
|
|
x, sr = p
|
|
self.wf_new.set_audio(x, sr)
|
|
dur = audio.duration(x, sr)
|
|
s = self.sound
|
|
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 "
|
|
"(lower quality) or refused, and the game may cut it off." % s.seconds)
|
|
if len(x) and np.max(np.abs(x)) >= 0.999:
|
|
warnings.append("Clipping: lower the gain or use Normalize.")
|
|
self._warn(warnings)
|
|
|
|
def _warn(self, warnings):
|
|
self.warn_icon.setVisible(bool(warnings))
|
|
self.lbl_warn.setText(" ".join(warnings))
|
|
|
|
# --- playback --------------------------------------------------------------------------------
|
|
def _audio_for(self, what):
|
|
return {"orig": self.orig, "inst": self.installed, "new": self.processed()}.get(what)
|
|
|
|
def play(self, what, start=0.0):
|
|
a = self._audio_for(what)
|
|
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):
|
|
if self.ctx.player.is_playing():
|
|
self.stop()
|
|
else:
|
|
self.play("new" if self.src else "orig")
|
|
|
|
def ab_switch(self):
|
|
t = self.ctx.player.current_time() if self.ctx.player.is_playing() else 0.0
|
|
nxt = "orig" if self.playing_what in ("new", "inst") else ("new" if self.src else "inst")
|
|
if self._audio_for(nxt):
|
|
self.play(nxt, t)
|
|
|
|
def stop(self):
|
|
self.ctx.player.stop()
|
|
self._clear_playheads()
|
|
|
|
def _clear_playheads(self):
|
|
for w in (self.wf_orig, self.wf_inst, self.wf_new):
|
|
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:
|
|
wf.set_playhead(t)
|
|
|
|
# --- apply / revert --------------------------------------------------------------------------
|
|
def _busy(self, msg):
|
|
self.busy.setVisible(bool(msg))
|
|
self.lbl_busy.setText(msg or "")
|
|
self.b_apply.setEnabled(not msg and self.sound is not None)
|
|
self.b_revert.setEnabled(not msg and self.sound is not None)
|
|
|
|
def _failed(self, what, e):
|
|
self._busy(None)
|
|
if isinstance(e, Exception) and str(e) == "cancelled":
|
|
return
|
|
self.inform(what, str(e))
|
|
|
|
def apply(self, force=False):
|
|
s = self.sound
|
|
p = self.processed()
|
|
if s is None:
|
|
return
|
|
if p is None:
|
|
self.inform("Nothing to apply", "Load an audio file or record a replacement first.")
|
|
return
|
|
x, sr = p
|
|
if not s.is_pack_media and audio.duration(x, sr) > s.seconds * 1.15 + 0.25 and not force:
|
|
if not self.confirm("Longer than the original",
|
|
"Your audio is %.2f s, the original %.2f s.\nIt must fit in the original's size, "
|
|
"so it will be encoded at lower quality (or refused), and the game may cut it "
|
|
"off.\n\nApply anyway?" % (audio.duration(x, sr), s.seconds)):
|
|
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…")
|
|
wem, info = ws.prepare(s, x, sr, progress=job.report)
|
|
job.report("writing game files…")
|
|
ws.apply([(s, wem, meta)], force=force, progress=job.report)
|
|
return info
|
|
|
|
def done(info):
|
|
self._busy(None)
|
|
note = " (shrunk to fit: %d Hz, quality %.1f)" % (info["rate"], info["quality"]) if "shrunk_from" in info else \
|
|
" (%d Hz, quality %.1f)" % (info["rate"], info["quality"])
|
|
self.message.emit("Applied %s%s" % (s.name, note))
|
|
self.applied.emit(s.key)
|
|
self.set_sound(s)
|
|
|
|
def failed(e, tb):
|
|
if isinstance(e, W.ConflictError):
|
|
self._busy(None)
|
|
if self.confirm("Changed outside this tool", str(e) + "\n\nOverwrite it with your replacement?"):
|
|
self.apply(force=True)
|
|
return
|
|
self._failed("Could not apply", e)
|
|
|
|
self._busy("encoding…")
|
|
self.ctx.runner.start(work, done, failed, lambda m: self.lbl_busy.setText(m))
|
|
|
|
def revert(self):
|
|
s = self.sound
|
|
if s is None:
|
|
return
|
|
ws = self.ctx.ws
|
|
|
|
def work(job):
|
|
return ws.revert([s], progress=job.report)
|
|
|
|
def done(r):
|
|
self._busy(None)
|
|
self.message.emit("Reverted %s to the original" % s.name if r else "%s is already original" % s.name)
|
|
self.reverted.emit(s.key)
|
|
self.set_sound(s)
|
|
|
|
self._busy("reverting…")
|
|
self.ctx.runner.start(work, done, lambda e, tb: self._failed("Could not revert", e))
|
|
|
|
def export_wav(self):
|
|
if not self.orig:
|
|
return
|
|
which = self.processed() if self.src else self.orig
|
|
p = self.ask_save(os.path.join(self.ctx.settings.get("last_dir") or "", self.sound.name + ".wav"))
|
|
if p:
|
|
audio.save_wav(p, which[0], which[1])
|
|
self.message.emit("Saved %s" % p)
|