531 lines
22 KiB
Python
531 lines
22 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)
|
|
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("")
|
|
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)
|