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