Files
UnheardCity/app/editor.py

540 lines
23 KiB
Python

"""Single-sound editor: listen, substitute (file or recording), process, apply / revert."""
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, QSlider,
QVBoxLayout, QWidget)
from ackit import audio, 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)
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.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
def _wave(self, kind, selectable=False):
w = Waveform(selectable=selectable)
w.accent, w.bg = theme.WAVE[kind], theme.WAVE_BG
return w
def _build(self):
self.setObjectName("EditorPanel")
lay = QVBoxLayout(self)
lay.setContentsMargins(10, 8, 10, 8)
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)
lay.addWidget(self.title)
self.meta = QLabel()
self.meta.setWordWrap(True)
self.meta.setObjectName("Muted")
self.state = QLabel()
self.state.setWordWrap(True)
lay.addWidget(self.meta)
lay.addWidget(self.state)
# listen ----------------------------------------------------------------------------------
g = QGroupBox("LISTEN")
gl = QGridLayout(g)
self.wf_orig = self._wave("orig")
self.wf_inst = self._wave("inst")
self.b_play_orig = self._btn("Original", "play", lambda: self.play("orig"), "Play the original (Space)", icons.STEEL)
self.b_play_inst = self._btn("Installed", "play", lambda: self.play("inst"), "Play what the game has now", "#8f4a4e")
self.b_stop = self._btn("Stop", "stop", self.stop, "Stop (Esc)")
self.b_ab = self._btn("A/B", "ab", self.ab_switch, "Switch between original and replacement at the same position",
icons.ACCENT)
self.cb_loop = self._btn("Loop", "loop", None, "Repeat playback")
self.cb_loop.setCheckable(True)
self.vol_icon = QLabel()
self.vol_icon.setPixmap(icons.icon("play", icons.MUTED).pixmap(14, 14))
self.sl_vol = QSlider(Qt.Orientation.Horizontal)
self.sl_vol.setRange(0, 100)
self.sl_vol.setValue(80)
self.sl_vol.setMaximumWidth(120)
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")
gl.addWidget(QLabel("Original"), 0, 0)
gl.addWidget(self.wf_orig, 0, 1, 1, 6)
self.lbl_inst = QLabel("Installed")
gl.addWidget(self.lbl_inst, 1, 0)
gl.addWidget(self.wf_inst, 1, 1, 1, 6)
row = QHBoxLayout()
for w in (self.b_play_orig, self.b_play_inst, self.b_stop, self.b_ab, self.cb_loop, QLabel(" Volume"),
self.sl_vol, self.lbl_time):
row.addWidget(w)
row.addStretch(1)
gl.addLayout(row, 2, 0, 1, 7)
gl.setColumnStretch(1, 1)
lay.addWidget(g)
self.wf_orig.seekRequested.connect(lambda t: self.play("orig", t))
self.wf_inst.seekRequested.connect(lambda t: self.play("inst", t))
# replace ---------------------------------------------------------------------------------
g = QGroupBox("REPLACE")
rl = QVBoxLayout(g)
src = QHBoxLayout()
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.cmb_in = QComboBox()
self.cmb_in.setMinimumWidth(180)
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.setMaximumWidth(120)
self.meter.setToolTip("Input level")
self.lbl_rec = QLabel("")
self.lbl_rec.setObjectName("Muted")
for w in (self.b_load, QLabel(" or"), self.cmb_in, self.b_rec, self.meter, self.lbl_rec):
src.addWidget(w)
src.addStretch(1)
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)
proc = QHBoxLayout()
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_.valueChanged.connect(self._changed)
self.sp_gain = QDoubleSpinBox()
self.sp_gain.setRange(-40, 40)
self.sp_gain.setSuffix(" dB")
self.sp_gain.valueChanged.connect(self._changed)
self.cb_norm = QCheckBox("Normalize")
self.cb_match = QCheckBox("Match original loudness")
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)
for w in (QLabel("Keep from"), self.sp_a, QLabel("to"), self.sp_b, self.b_silence, QLabel(" Gain"),
self.sp_gain, self.cb_norm, self.cb_match, self.b_play_new):
proc.addWidget(w)
proc.addStretch(1)
rl.addLayout(proc)
info = QHBoxLayout()
self.lbl_new = QLabel()
self.lbl_new.setWordWrap(True)
self.lbl_new.setObjectName("Muted")
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)
self.lbl_new.setWordWrap(False)
info.addWidget(self.lbl_new, 0, Qt.AlignmentFlag.AlignTop)
info.addSpacing(12)
info.addWidget(self.warn_icon, 0, Qt.AlignmentFlag.AlignTop)
info.addWidget(self.lbl_warn, 1)
rl.addLayout(info)
lay.addWidget(g)
# apply -----------------------------------------------------------------------------------
act = QHBoxLayout()
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 to original", "revert", self.revert, "Put the game's original back")
self.b_export = self._btn("Save as WAV…", "save", self.export_wav,
"Save the original (or the replacement) as a WAV file")
self.busy = Spinner(16)
self.lbl_busy = QLabel()
for w in (self.b_apply, self.b_revert, self.b_export, self.busy, self.lbl_busy):
act.addWidget(w)
act.addStretch(1)
lay.addLayout(act)
# subtitle of this sound (edited side by side with the audio) -------------------------------
from .textpanel import SubtitleBox
gs = QGroupBox("Subtitle")
gsl = QVBoxLayout(gs)
self.subtitle = SubtitleBox(self.ctx, gs)
gsl.addWidget(self.subtitle)
lay.addWidget(gs)
lay.addStretch(1)
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
for w in self.findChildren(QPushButton) + [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)
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):
self.src, self.src_name = (np.asarray(x, np.float32), sr), name
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()
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()
def processed(self):
"""Replacement audio after trim / gain / normalize / loudness matching -> (x, rate) or None."""
if not self.src:
return None
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 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):
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
self.lbl_new.setText("%s: %.2f s, peak %.1f dBFS" % (self.src_name, 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.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):
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}
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)