Files
UnheardCity/app/editor.py
Cristian Gora 1ccaa8b398 Full correctness check against the docs: safer writes, reverts and texts
Safety
- Writes are serialized (Workspace.lock): two background jobs rebuilding one
  package (a sound and its subtitle applied side by side) lost an edit.
- A store copies a bank file or package as "original" only if it matches the
  shipped catalog (Workspace.unoriginal_files: in-memory sounds, bank copies of
  voice lines, subtitles); the editor refuses to switch stores while changes
  are applied. A second store could otherwise resurrect a reverted sound.
- Mod import in the editor asks before overwriting files changed outside the
  tool (it forced silently).
- Revert all in the editor reverts texts too; the uninstaller counts text
  edits before offering to delete the store.
- ackit restore-backup: the full-backup restore decisions.md described; it
  keeps the sounds of WADs without a full copy recorded.
- build_catalog also stops when a subtitle differs from the catalog.

Languages and texts
- Game.language() returns languages without voices (JPN, RUS, ...) and
  Game.voice_archive() their INT voices; ackit text --lang JPN works.
- Menu lookups parse one cached file (find & replace: minutes -> ms).
- Subtitles decode cp1252 as Windows does: the UTF-8-in-cp1252 line
  Speech_TAPE_J_Answer_1_00 (KOR/POL/POR/RUS) is written back byte-for-byte;
  SUB_KOR/SUB_RUS rebuilt from the original game (only those rows changed).
- Mods exported while another language is loaded keep name and line.

Other fixes
- Down-mix folds extra channels in at -3 dB (a 5.1 centre voice was dropped).
- e2e_editor no longer overwrites the user's settings.json; kill_game waits
  for the game to exit before reverts.
- Recorder handles Int32/UInt8 input and split frames; package block cache
  locked; conflict messages, CLI help examples, stale docstrings and doc
  references fixed; tests no longer read this PC's game language.

Docs (decisions, user guide, README, subtitles.md, banks.md, catalog README,
REPORT, NOTES, CLAUDE.md) updated; check_docs: 102 claims match. 148 tests;
e2e_features 9/9, e2e_editor and verify PASS; game original afterwards.
2026-10-13 21:34:13 +02:00

591 lines
25 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, QSplitter,
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
@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
return w
def _build(self):
"""Header on top; below, side by side (resizable): LISTEN | REPLACE (with the apply buttons) | SUBTITLE."""
self.setObjectName("EditorPanel")
lay = QVBoxLayout(self)
lay.setContentsMargins(8, 6, 8, 6)
lay.setSpacing(4)
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)
# listen ----------------------------------------------------------------------------------
g = QGroupBox("LISTEN")
gl = QGridLayout(g)
gl.setContentsMargins(8, 8, 8, 8)
gl.setVerticalSpacing(4)
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 (double-click the row; Space until a replacement is loaded)",
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)")
# playback options next to the volume: icons with tooltips
self.b_ab = self._btn("", "ab", self.ab_switch, "A/B: switch between the original and the replacement at "
"the same position", icons.ACCENT)
self.cb_loop = self._btn("", "loop", None, "Loop: repeat playback")
self.cb_loop.setCheckable(True)
for b in (self.b_ab, self.cb_loop):
b.setFixedWidth(b.sizeHint().height() + 4)
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)
gl.addWidget(QLabel("Original"), 0, 0)
gl.addWidget(self.wf_orig, 0, 1)
self.lbl_inst = QLabel("Installed")
gl.addWidget(self.lbl_inst, 1, 0)
gl.addWidget(self.wf_inst, 1, 1)
gl.setRowStretch(2, 1) # controls at the bottom, level with the other columns' actions
gl.addLayout(self._fill((self.b_play_orig, 1), (self.b_play_inst, 1), (self.b_stop, 1)), 4, 0, 1, 2)
vol = QHBoxLayout() # playback options: A/B, loop, volume, position
vol.setSpacing(6)
vol.addWidget(self.b_ab)
vol.addWidget(self.cb_loop)
vol.addSpacing(6)
vol.addWidget(QLabel("Volume"))
vol.addWidget(self.sl_vol, 1)
vol.addWidget(self.lbl_time)
gl.addLayout(vol, 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))
listen = g
# replace ---------------------------------------------------------------------------------
g = QGroupBox("REPLACE")
rl = QVBoxLayout(g)
rl.setContentsMargins(8, 8, 8, 8)
rl.setSpacing(6)
src = QHBoxLayout()
src.setSpacing(6)
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(110)
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.setFixedWidth(60)
self.meter.setToolTip("Input level")
self.lbl_rec = QLabel("")
self.lbl_rec.setObjectName("Muted")
src.addWidget(self.b_load, 1) # the row fills the column: file | microphone, level
src.addSpacing(10)
src.addWidget(self.cmb_in, 2)
src.addWidget(self.b_rec, 1)
for w in (self.meter, self.lbl_rec):
src.addWidget(w)
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)
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(80)
s_.valueChanged.connect(self._changed)
self.sp_gain = QDoubleSpinBox()
self.sp_gain.setRange(-40, 40)
self.sp_gain.setSuffix(" dB")
self.sp_gain.setMaximumWidth(80)
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")
self.b_play_new = self._btn("Replacement", "play", lambda: self.play("new"), "Play the processed replacement",
icons.ACCENT)
# edit controls in a grid: labels in one column, the fields aligned, playback on the right
proc = QGridLayout()
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.addLayout(self._fill((self.b_silence, 1), (self.b_play_new, 1)), 0, 4) # the rest of the width
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(4, 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)
# apply (belongs to the replacement) ------------------------------------------------------
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 a WAV file")
self.busy = Spinner(16)
self.lbl_busy = QLabel()
info.addWidget(self.busy, 0, Qt.AlignmentFlag.AlignTop) # encoding/writing: next to the status
info.addWidget(self.lbl_busy, 0, Qt.AlignmentFlag.AlignTop)
act = self._fill((self.b_apply, 2), (self.b_revert, 1), (self.b_export, 1))
rl.addStretch(1)
rl.addLayout(act)
replace = g
# subtitle of this sound (edited side by side with the audio) -------------------------------
from .textpanel import SubtitleBox
gs = QGroupBox("SUBTITLE")
gsl = QVBoxLayout(gs)
gsl.setContentsMargins(8, 8, 8, 8)
self.subtitle = SubtitleBox(self.ctx, gs)
gsl.addWidget(self.subtitle, 1)
self.columns = QSplitter(Qt.Orientation.Horizontal)
self.columns.setChildrenCollapsible(False)
self.columns.setHandleWidth(4)
for w, stretch in ((listen, 3), (replace, 4), (gs, 3)):
self.columns.addWidget(w)
self.columns.setStretchFactor(self.columns.count() - 1, stretch)
lay.addWidget(self.columns, 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)