"""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)