Initial commit

This commit is contained in:
2026-10-09 02:48:24 +02:00
commit e05d973a8d
133 changed files with 101507 additions and 0 deletions

1
app/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Unheard City (PySide6 GUI on top of ackit)."""

250
app/audioio.py Normal file
View File

@@ -0,0 +1,250 @@
"""Playback and recording with Qt Multimedia (bundled with PySide6; WASAPI on Windows,
PulseAudio/PipeWire on Linux). Audio is exchanged as float32 numpy arrays (frames, channels).
Set ACKIT_NO_AUDIO=1 to disable devices (headless tests); `Recorder.start_simulated` feeds a recording from
an array in real time, so the recording logic is testable without a microphone.
"""
import os
import numpy as np
from PySide6.QtCore import QBuffer, QByteArray, QIODevice, QObject, QTimer, Signal
from PySide6.QtMultimedia import QAudio, QAudioFormat, QAudioSink, QAudioSource, QMediaDevices
from ackit import audio
def audio_enabled():
return os.environ.get("ACKIT_NO_AUDIO") != "1"
def output_devices():
return list(QMediaDevices.audioOutputs()) if audio_enabled() else []
def input_devices():
return list(QMediaDevices.audioInputs()) if audio_enabled() else []
def find_device(devices, description):
return next((d for d in devices if d.description() == description), None)
def _to_int16(x):
return (np.clip(x, -1.0, 1.0) * 32767.0).astype("<i2").tobytes()
class Player(QObject):
"""Plays one buffer at a time. Signals: position(seconds), playing(bool), error(str)."""
position = Signal(float)
playing = Signal(bool)
error = Signal(str)
def __init__(self, parent=None):
super().__init__(parent)
self.device = None
self.sink = None
self.buffer = None
self.data = QByteArray()
self.fmt = None
self.base = 0.0
self.duration = 0.0
self.loop = False
self.volume = 1.0
self.label = None
self.timer = QTimer(self)
self.timer.setInterval(40)
self.timer.timeout.connect(self._tick)
def set_device(self, device):
self.stop()
self.device = device
def _device(self):
if self.device is not None and not self.device.isNull():
return self.device
d = QMediaDevices.defaultAudioOutput() if audio_enabled() else None
return d if d is not None and not d.isNull() else None
def play(self, x, rate, start=0.0, loop=False, label=None):
"""x: float32 (n, ch). Converted to the device's format (rate, channels, 16-bit)."""
self.stop()
dev = self._device()
if dev is None:
self.error.emit("No audio output device available.")
return False
pref = dev.preferredFormat()
fmt = QAudioFormat()
fmt.setSampleRate(pref.sampleRate() or 48000)
fmt.setChannelCount(min(2, pref.channelCount() or 2))
fmt.setSampleFormat(QAudioFormat.SampleFormat.Int16)
if not dev.isFormatSupported(fmt):
fmt.setChannelCount(2)
fmt.setSampleRate(48000)
y = audio.to_channels(audio.resample(x, rate, fmt.sampleRate()), fmt.channelCount())
self.fmt, self.loop, self.label = fmt, loop, label
self.data = QByteArray(_to_int16(y))
self.duration = len(y) / float(fmt.sampleRate())
self.sink = QAudioSink(dev, fmt, self)
self.sink.setVolume(self.volume)
self.sink.stateChanged.connect(self._state)
self._start_at(start)
return True
def _start_at(self, seconds):
seconds = max(0.0, min(seconds, self.duration))
if self.buffer is not None:
self.buffer.close()
self.buffer = QBuffer(self)
self.buffer.setData(self.data)
self.buffer.open(QIODevice.OpenModeFlag.ReadOnly)
frame = self.fmt.bytesPerFrame()
self.buffer.seek(int(seconds * self.fmt.sampleRate()) * frame)
self.base = seconds
self.sink.start(self.buffer)
self.timer.start()
self.playing.emit(True)
def seek(self, seconds):
if self.sink is not None:
self.sink.stop()
self._start_at(seconds)
def set_volume(self, v):
self.volume = float(v)
if self.sink is not None:
self.sink.setVolume(self.volume)
def is_playing(self):
return self.sink is not None and self.sink.state() == QAudio.State.ActiveState
def current_time(self):
if self.sink is None:
return 0.0
return self.base + self.sink.processedUSecs() / 1e6
def _tick(self):
self.position.emit(min(self.current_time(), self.duration))
def _state(self, st):
if st == QAudio.State.IdleState: # reached the end of the buffer
if self.loop:
self.sink.stop()
self._start_at(0.0)
else:
self.stop()
def stop(self):
self.timer.stop()
if self.sink is not None:
try:
self.sink.stateChanged.disconnect(self._state)
except (RuntimeError, TypeError):
pass
self.sink.stop()
self.sink.deleteLater()
self.sink = None
self.playing.emit(False)
if self.buffer is not None:
self.buffer.close()
self.buffer = None
class Recorder(QObject):
"""Records from an input device. Signals: level(peak 0..1), elapsed(seconds), error(str)."""
level = Signal(float)
elapsed = Signal(float)
error = Signal(str)
def __init__(self, parent=None):
super().__init__(parent)
self.device = None
self.source = None
self.io = None
self.chunks = []
self.rate = 48000
self.channels = 1
self.fmt_float = False
self._sim = None
def set_device(self, device):
self.device = device
def is_recording(self):
return self.source is not None or self._sim is not None
def start(self):
self.chunks = []
dev = self.device if (self.device is not None and not self.device.isNull()) else (
QMediaDevices.defaultAudioInput() if audio_enabled() else None)
if dev is None or dev.isNull():
self.error.emit("No audio input device available.")
return False
fmt = QAudioFormat()
fmt.setSampleRate(48000)
fmt.setChannelCount(1)
fmt.setSampleFormat(QAudioFormat.SampleFormat.Int16)
if not dev.isFormatSupported(fmt):
fmt = dev.preferredFormat()
self.rate, self.channels = fmt.sampleRate(), fmt.channelCount()
self.fmt_float = fmt.sampleFormat() == QAudioFormat.SampleFormat.Float
self.source = QAudioSource(dev, fmt, self)
self.io = self.source.start()
if self.io is None:
self.error.emit("Could not open the input device.")
self.source = None
return False
self.io.readyRead.connect(self._read)
return True
def _read(self):
if self.io is None:
return
self._ingest(bytes(self.io.readAll()))
def _ingest(self, raw):
if not raw:
return
if self.fmt_float:
x = np.frombuffer(raw, dtype="<f4")
else:
x = np.frombuffer(raw[: len(raw) // 2 * 2], dtype="<i2").astype(np.float32) / 32768.0
x = x[: len(x) // self.channels * self.channels].reshape(-1, self.channels)
self.chunks.append(x)
self.level.emit(float(np.max(np.abs(x))) if len(x) else 0.0)
self.elapsed.emit(sum(len(c) for c in self.chunks) / float(self.rate))
# simulated input (tests / no microphone) ----------------------------------------------------
def start_simulated(self, x, rate, realtime=True):
"""Feed `x` as if it came from a microphone (20 ms blocks)."""
self.chunks = []
self.rate, self.channels, self.fmt_float = rate, x.shape[1], True
blocks = [x[i:i + rate // 50] for i in range(0, len(x), rate // 50)]
if not realtime:
for b in blocks:
self._ingest(np.ascontiguousarray(b, dtype="<f4").tobytes())
return True
self._sim = (QTimer(self), iter(blocks))
def step():
b = next(self._sim[1], None) if self._sim else None
if b is None:
if self._sim:
self._sim[0].stop()
return
self._ingest(np.ascontiguousarray(b, dtype="<f4").tobytes())
self._sim[0].timeout.connect(step)
self._sim[0].start(20)
return True
def stop(self):
"""-> (float32 array (n, ch), rate) of everything recorded."""
if self.source is not None:
self._read()
self.source.stop()
self.source.deleteLater()
self.source = None
self.io = None
if self._sim is not None:
self._sim[0].stop()
self._sim = None
x = np.concatenate(self.chunks) if self.chunks else np.zeros((0, self.channels), np.float32)
return x, self.rate

163
app/dialogs.py Normal file
View File

@@ -0,0 +1,163 @@
"""Settings, mod export and mod import dialogs."""
import os
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFileDialog, QFormLayout, QHBoxLayout,
QLabel, QLineEdit, QListWidget, QListWidgetItem, QPlainTextEdit, QPushButton, QSlider,
QTreeWidget, QTreeWidgetItem, QVBoxLayout)
from ackit.game import find_installs, is_game_dir
from .audioio import output_devices
def pick_game_dir(parent, start=""):
"""Folder chooser that explains what to pick (also Proton/Linux paths)."""
d = QFileDialog.getExistingDirectory(
parent, "Select the Batman: Arkham City folder (contains BmGame and Binaries)", start or os.path.expanduser("~"))
return d or None
class SettingsDialog(QDialog):
def __init__(self, settings, parent=None):
super().__init__(parent)
self.setWindowTitle("Settings")
self.settings = dict(settings)
f = QFormLayout(self)
self.game = QLineEdit(settings.get("game_dir") or "")
b = QPushButton("Browse…")
b.clicked.connect(self._browse_game)
row = QHBoxLayout()
row.addWidget(self.game, 1)
row.addWidget(b)
found = find_installs()
hint = QLabel("Found via Steam: " + ("; ".join(found) if found else "none (Linux/Proton: "
"~/.steam/steam/steamapps/common/Batman Arkham City GOTY)"))
hint.setWordWrap(True)
f.addRow("Game folder", row)
f.addRow("", hint)
self.store = QLineEdit(settings.get("store_root") or "")
self.store.setPlaceholderText("default: in your user data folder")
b2 = QPushButton("Browse…")
b2.clicked.connect(lambda: self._browse(self.store))
row2 = QHBoxLayout()
row2.addWidget(self.store, 1)
row2.addWidget(b2)
f.addRow("Originals store", row2)
self.full = QCheckBox("Keep one full copy of each audio archive before its first change (safest)")
self.full.setChecked(settings.get("full_backup", True))
f.addRow("", self.full)
self.out = QComboBox()
self.out.addItem("Default output")
for d in output_devices():
self.out.addItem(d.description())
if settings.get("output_device"):
i = self.out.findText(settings["output_device"])
if i >= 0:
self.out.setCurrentIndex(i)
f.addRow("Playback device", self.out)
# appearance
self.bg_on = QCheckBox("Show the Arkham City wallpaper behind the filters")
self.bg_on.setChecked(settings.get("background_enabled", True))
f.addRow("Background", self.bg_on)
self.opacity = QSlider(Qt.Orientation.Horizontal)
self.opacity.setRange(0, 100)
self.opacity.setValue(int(round(float(settings.get("background_opacity", 0.1)) * 100)))
f.addRow("Background strength", self.opacity)
self.bg_on.toggled.connect(self.opacity.setEnabled)
self.opacity.setEnabled(self.bg_on.isChecked())
bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
bb.accepted.connect(self.accept)
bb.rejected.connect(self.reject)
f.addRow(bb)
self.resize(640, 0)
def _browse_game(self):
d = pick_game_dir(self, self.game.text())
if d:
self.game.setText(d)
def _browse(self, edit):
d = QFileDialog.getExistingDirectory(self, "Choose folder", edit.text())
if d:
edit.setText(d)
def values(self):
s = dict(self.settings)
gd = self.game.text().strip()
s["game_dir"] = gd if is_game_dir(gd) else s.get("game_dir")
s["store_root"] = self.store.text().strip() or None
s["full_backup"] = self.full.isChecked()
s["output_device"] = self.out.currentText() if self.out.currentIndex() > 0 else None
s["background_enabled"] = self.bg_on.isChecked()
s["background_opacity"] = self.opacity.value() / 100.0
return s
class ExportModDialog(QDialog):
"""Pick which modified sounds go into a .acmod package."""
def __init__(self, items, parent=None):
"""items: [(key, name)]"""
super().__init__(parent)
self.setWindowTitle("Export mod package")
lay = QVBoxLayout(self)
f = QFormLayout()
self.title = QLineEdit()
self.author = QLineEdit()
self.desc = QPlainTextEdit()
self.desc.setMaximumHeight(70)
f.addRow("Title", self.title)
f.addRow("Author", self.author)
f.addRow("Description", self.desc)
lay.addLayout(f)
lay.addWidget(QLabel("Sounds to include:"))
self.list = QListWidget()
for key, name in items:
it = QListWidgetItem(name)
it.setData(Qt.ItemDataRole.UserRole, key)
it.setFlags(it.flags() | Qt.ItemFlag.ItemIsUserCheckable)
it.setCheckState(Qt.CheckState.Checked)
self.list.addItem(it)
lay.addWidget(self.list)
bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel)
bb.accepted.connect(self.accept)
bb.rejected.connect(self.reject)
lay.addWidget(bb)
self.resize(520, 480)
def selected_keys(self):
return [self.list.item(i).data(Qt.ItemDataRole.UserRole) for i in range(self.list.count())
if self.list.item(i).checkState() == Qt.CheckState.Checked]
class ImportModDialog(QDialog):
"""Shows how each item of a package matches this game before installing."""
def __init__(self, manifest, items, parent=None):
super().__init__(parent)
self.setWindowTitle("Import mod package")
lay = QVBoxLayout(self)
head = "<b>%s</b>" % (manifest.get("name") or "Mod package")
if manifest.get("author"):
head += " by %s" % manifest["author"]
lab = QLabel(head + ("<br>" + manifest["description"] if manifest.get("description") else ""))
lab.setWordWrap(True)
lay.addWidget(lab)
self.tree = QTreeWidget()
self.tree.setHeaderLabels(["Sound", "Match", "Note"])
n_ok = 0
for im in items:
note = {"exact": "same sound", "by name": "matched by name (other language) - will be re-encoded",
"missing": im.note}[im.how]
QTreeWidgetItem(self.tree, [im.item.get("name") or im.item["target"], im.how, note])
n_ok += im.how != "missing"
self.tree.resizeColumnToContents(0)
lay.addWidget(self.tree)
lay.addWidget(QLabel("%d of %d sounds will be installed." % (n_ok, len(items))))
bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
bb.button(QDialogButtonBox.StandardButton.Ok).setText("Install")
bb.button(QDialogButtonBox.StandardButton.Ok).setEnabled(n_ok > 0)
bb.accepted.connect(self.accept)
bb.rejected.connect(self.reject)
lay.addWidget(bb)
self.resize(720, 420)

526
app/editor.py Normal file
View File

@@ -0,0 +1,526 @@
"""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)
self.meta.setText("%s / %s • %s • %.2f s, %d Hz, %s • max size: %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))
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)

187
app/icons.py Normal file
View File

@@ -0,0 +1,187 @@
"""Vector icons drawn with QPainter (one consistent style, no image files, no emoji).
`icon(name, color)` returns a QIcon rendered at several sizes. Names: play, stop, record, open, apply, revert,
save, export, import, settings, star, star-off, warning, ab, loop, search, wave.
`app_icon()` uses the artwork in app/assets (made by tools/make_assets.py from the user's images).
"""
import math
import os
from PySide6.QtCore import QPointF, QRectF, Qt
from PySide6.QtGui import QColor, QIcon, QPainter, QPainterPath, QPen, QPixmap
ACCENT = "#b0242c"
STEEL = "#c4c4c4"
TEXT = "#e6e6e6"
DANGER = "#e0533d"
MUTED = "#9a9a9a"
_cache = {}
def _arrow_arc(p, r, start, span, head=True):
rect = QRectF(-r, -r, 2 * r, 2 * r)
path = QPainterPath()
path.arcMoveTo(rect, start)
path.arcTo(rect, start, span)
p.drawPath(path)
if head:
a = math.radians(start + span)
tip = QPointF(r * math.cos(a), -r * math.sin(a))
d = 1 if span > 0 else -1
tang = math.radians(start + span + 90 * d)
back = QPointF(math.cos(tang), -math.sin(tang))
nrm = QPointF(math.cos(a), -math.sin(a))
L = r * 0.55
p.drawLine(tip, tip - back * L + nrm * L * 0.6)
p.drawLine(tip, tip - back * L - nrm * L * 0.6)
def _draw(p, name, c, s):
"""Draw icon `name` in a box of size s (origin top-left)."""
pen = QPen(QColor(c), max(1.2, s * 0.09), Qt.PenStyle.SolidLine, Qt.PenCapStyle.RoundCap, Qt.PenJoinStyle.RoundJoin)
p.setPen(pen)
p.setBrush(Qt.BrushStyle.NoBrush)
m = s * 0.18
if name == "play":
path = QPainterPath()
path.moveTo(s * 0.30, s * 0.20)
path.lineTo(s * 0.80, s * 0.50)
path.lineTo(s * 0.30, s * 0.80)
path.closeSubpath()
p.setBrush(QColor(c))
p.drawPath(path)
elif name == "stop":
p.setBrush(QColor(c))
p.drawRoundedRect(QRectF(s * 0.26, s * 0.26, s * 0.48, s * 0.48), s * 0.06, s * 0.06)
elif name == "record":
p.setPen(Qt.PenStyle.NoPen)
p.setBrush(QColor(DANGER))
p.drawEllipse(QRectF(s * 0.22, s * 0.22, s * 0.56, s * 0.56))
elif name == "open":
path = QPainterPath()
path.moveTo(m, s * 0.30)
path.lineTo(s * 0.40, s * 0.30)
path.lineTo(s * 0.48, s * 0.38)
path.lineTo(s - m, s * 0.38)
path.lineTo(s - m, s * 0.78)
path.lineTo(m, s * 0.78)
path.closeSubpath()
p.drawPath(path)
elif name == "apply":
path = QPainterPath()
path.moveTo(s * 0.20, s * 0.52)
path.lineTo(s * 0.42, s * 0.74)
path.lineTo(s * 0.82, s * 0.28)
p.drawPath(path)
elif name in ("revert", "loop"):
p.translate(s / 2, s / 2)
if name == "revert":
_arrow_arc(p, s * 0.30, 60, 270)
else:
_arrow_arc(p, s * 0.30, 20, 150)
_arrow_arc(p, s * 0.30, 200, 150)
p.translate(-s / 2, -s / 2)
elif name == "save": # disk
path = QPainterPath()
path.moveTo(m, m)
path.lineTo(s * 0.70, m)
path.lineTo(s - m, s * 0.30)
path.lineTo(s - m, s - m)
path.lineTo(m, s - m)
path.closeSubpath()
p.drawPath(path)
p.drawRect(QRectF(s * 0.32, m, s * 0.30, s * 0.20))
p.drawRect(QRectF(s * 0.30, s * 0.56, s * 0.40, s * 0.26))
elif name in ("export", "import"):
p.drawLine(QPointF(m, s * 0.62), QPointF(m, s - m))
p.drawLine(QPointF(m, s - m), QPointF(s - m, s - m))
p.drawLine(QPointF(s - m, s - m), QPointF(s - m, s * 0.62))
up = name == "export"
a, b = (s * 0.66, s * 0.16) if up else (s * 0.16, s * 0.66)
p.drawLine(QPointF(s / 2, a), QPointF(s / 2, b))
d = -1 if up else 1
p.drawLine(QPointF(s / 2, b), QPointF(s / 2 - s * 0.16, b - d * s * 0.16))
p.drawLine(QPointF(s / 2, b), QPointF(s / 2 + s * 0.16, b - d * s * 0.16))
elif name == "settings":
p.translate(s / 2, s / 2)
path = QPainterPath()
teeth = 8
for i in range(teeth * 2):
a = math.pi * i / teeth
r = s * (0.36 if i % 2 == 0 else 0.27)
pt = QPointF(r * math.cos(a), r * math.sin(a))
path.moveTo(pt) if i == 0 else path.lineTo(pt)
path.closeSubpath()
p.drawPath(path)
p.drawEllipse(QPointF(0, 0), s * 0.11, s * 0.11)
p.translate(-s / 2, -s / 2)
elif name in ("star", "star-off"):
path = QPainterPath()
for i in range(10):
a = -math.pi / 2 + math.pi * i / 5
r = s * (0.40 if i % 2 == 0 else 0.17)
pt = QPointF(s / 2 + r * math.cos(a), s * 0.54 + r * math.sin(a))
path.moveTo(pt) if i == 0 else path.lineTo(pt)
path.closeSubpath()
if name == "star":
p.setBrush(QColor(c))
p.drawPath(path)
elif name == "warning":
path = QPainterPath()
path.moveTo(s / 2, s * 0.14)
path.lineTo(s * 0.88, s * 0.84)
path.lineTo(s * 0.12, s * 0.84)
path.closeSubpath()
p.drawPath(path)
p.drawLine(QPointF(s / 2, s * 0.40), QPointF(s / 2, s * 0.60))
p.drawPoint(QPointF(s / 2, s * 0.72))
elif name == "ab": # compare: half-filled circle
rect = QRectF(s * 0.18, s * 0.18, s * 0.64, s * 0.64)
half = QPainterPath()
half.moveTo(s / 2, s * 0.18)
half.arcTo(rect, 90, 180)
half.closeSubpath()
p.setBrush(QColor(c))
p.drawPath(half)
p.setBrush(Qt.BrushStyle.NoBrush)
p.drawEllipse(rect)
elif name == "search":
p.drawEllipse(QRectF(s * 0.18, s * 0.18, s * 0.46, s * 0.46))
p.drawLine(QPointF(s * 0.58, s * 0.58), QPointF(s * 0.84, s * 0.84))
elif name == "wave": # fallback app glyph: waveform bars
p.setPen(Qt.PenStyle.NoPen)
p.setBrush(QColor(c))
for i, h in enumerate((0.25, 0.5, 0.8, 0.45, 0.65, 0.3)):
p.drawRoundedRect(QRectF(s * (0.14 + i * 0.13), s * (0.5 - h / 2), s * 0.08, s * h), s * 0.03, s * 0.03)
def icon(name, color=TEXT):
key = (name, color)
if key not in _cache:
ic = QIcon()
for sz in (16, 20, 24, 32, 48, 64, 128, 256):
pm = QPixmap(sz, sz)
pm.fill(Qt.GlobalColor.transparent)
p = QPainter(pm)
p.setRenderHint(QPainter.RenderHint.Antialiasing)
_draw(p, name, color, sz)
p.end()
ic.addPixmap(pm)
_cache[key] = ic
return _cache[key]
ASSETS = os.path.join(os.path.dirname(os.path.abspath(__file__)), "assets")
def asset(name):
p = os.path.join(ASSETS, name)
return p if os.path.exists(p) else None
def app_icon():
"""The app icon (Arkham City logo bat sending out sound arcs); a plain waveform glyph if the assets are missing."""
p = asset("app_icon.ico") or asset("app_icon.png")
return QIcon(p) if p else icon("wave", ACCENT)

91
app/main.py Normal file
View File

@@ -0,0 +1,91 @@
"""Unheard City - entry point.
UnheardCity [--game DIR] start the editor
UnheardCity --smoke-test [--game DIR] headless self-test of the bundled pipeline (no window, never
writes the game: injects into a temporary copy of a WAD)
UnheardCity --quit-after 5 --screenshot shot.png (start, wait, screenshot, quit; for build checks)
"""
import argparse
import os
import sys
def smoke_test(game_dir=None, report=None):
"""Prove the packaged app can decode, encode and inject without any external program."""
lines = []
def print(*a): # noqa: A001 - also captured into the report (windowed builds have no console)
lines.append(" ".join(str(x) for x in a))
if sys.stdout:
sys.stdout.write(lines[-1] + "\n")
if report:
with open(report, "w", encoding="utf-8") as f:
f.write("\n".join(lines) + "\n")
import shutil
import tempfile
import numpy as np
from ackit import audio, settings
from ackit.game import Game, default_game_dir
from ackit.inject import inject, read_target
from ackit.workspace import Workspace
gd = game_dir or default_game_dir(settings.load())
print("ffmpeg on PATH:", shutil.which("ffmpeg"), "| vgmstream on PATH:", shutil.which("vgmstream-cli"))
if not gd:
print("no game found; testing the codec only")
from ackit.encoder import encode
t = np.arange(48000) / 48000.0
x = (0.3 * np.sin(2 * np.pi * 440 * t)).astype(np.float32)[:, None]
wem, info = encode(x, 48000, 24000, 1)
y, sr = audio.decode_wem(wem)
print("codec OK: %d bytes, decoded %.2f s at %d Hz" % (len(wem), len(y) / sr, sr))
return 0
with tempfile.TemporaryDirectory() as td:
ws = Workspace(Game(gd), store_root=os.path.join(td, "store"))
s = next(x for x in ws.catalog.sounds if x.group == "Speech" and 1.0 < x.seconds < 4.0)
x, sr = audio.decode_wem(ws.original_bytes(s))
wem, info = ws.prepare(s, x[::-1].copy(), sr)
out = os.path.join(td, os.path.basename(ws.wad_path(s)))
inject(ws.wad_path(s), {s.target: wem}, {s.target: ws.limit(s)}, out_path=out)
assert read_target(out, s.target) == wem
print("pipeline OK: %s (%s), %d sounds loaded, re-encoded %d bytes (limit %d), injected into a temp copy"
% (s.name, s.archive, len(ws.catalog.sounds), len(wem), ws.limit(s)))
return 0
def main(argv=None):
ap = argparse.ArgumentParser(prog="UnheardCity")
ap.add_argument("--game", help="Arkham City folder")
ap.add_argument("--smoke-test", action="store_true")
ap.add_argument("--quit-after", type=float)
ap.add_argument("--screenshot")
ap.add_argument("--report", help="write the smoke-test output to this file")
a, rest = ap.parse_known_args(argv)
if a.smoke_test:
return smoke_test(a.game, a.report)
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QApplication
from ackit import settings as settings_mod
from . import icons, theme
from .mainwindow import MainWindow
app = QApplication([sys.argv[0]] + rest)
app.setApplicationName("Unheard City")
app.setOrganizationName("UnheardCity")
app.setWindowIcon(icons.app_icon())
st = settings_mod.load()
theme.apply(app)
w = MainWindow(settings=st, game_dir=a.game)
w.show()
if a.quit_after:
def finish():
if a.screenshot:
w.grab().save(a.screenshot)
app.quit()
QTimer.singleShot(int(a.quit_after * 1000), finish)
return app.exec()
if __name__ == "__main__":
sys.exit(main())

640
app/mainwindow.py Normal file
View File

@@ -0,0 +1,640 @@
"""Main window: filters + sound table (browser) on top, single-sound editor below."""
import os
from PySide6.QtCore import QByteArray, QEvent, QItemSelectionModel, QSize, Qt, QTimer
from PySide6.QtGui import QFont, QKeySequence, QShortcut
from PySide6.QtWidgets import (QAbstractItemView, QCheckBox, QComboBox, QDialog, QFileDialog, QFormLayout,
QHBoxLayout, QHeaderView, QLabel, QLineEdit, QMainWindow, QMenu, QMessageBox,
QPushButton, QScrollArea, QSplitter, QTableView, QToolBar, QVBoxLayout, QWidget)
from ackit import settings as settings_mod, workspace as W
from ackit.game import Game, GameError, default_game_dir
from ackit.modpack import export_mod, match_mod, prepare_import
from . import icons, theme
from .audioio import Player, find_device, output_devices
from .dialogs import ExportModDialog, ImportModDialog, SettingsDialog, pick_game_dir
from .editor import EditorPanel
from .models import C_FAV, C_NAME, COLS, SoundFilter, SoundModel
from .spinner import Spinner
from .userdata import UserData
from .workers import Runner
SIDEBAR_MIN, SIDEBAR_MAX = 220, 320
TABLE_MIN = 160 # the sound list never gets smaller than this when fitting the editor
ANY = "Any"
class MainWindow(QMainWindow):
def __init__(self, settings=None, runner=None, game_dir=None):
super().__init__()
self.setWindowTitle("Unheard City")
self.settings = settings if settings is not None else settings_mod.load()
self.runner = runner or Runner()
self.player = Player(self)
self.player.error.connect(lambda m: self.statusBar().showMessage(m, 5000))
self.userdata = UserData()
self.ws = None
self.loading = False
self._build()
self._apply_output_device()
geo = self.settings.get("window_geometry")
if geo:
self.restoreGeometry(QByteArray.fromHex(geo.encode()))
else:
self.resize(1400, 900)
gd = game_dir or default_game_dir(self.settings)
if gd:
QTimer.singleShot(0, lambda: self.load_game(gd, self.settings.get("language")))
else:
self.show_no_game()
# --- UI ---------------------------------------------------------------------------------------
def _build(self):
self.setWindowIcon(icons.app_icon())
tb = QToolBar("Main")
tb.setMovable(False)
tb.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon)
tb.setIconSize(QSize(18, 18))
self.addToolBar(tb)
logo = QLabel()
logo.setPixmap(icons.app_icon().pixmap(28, 28))
tb.addWidget(logo)
title = QLabel("UNHEARD CITY")
title.setObjectName("AppTitle")
f = title.font()
f.setBold(True)
f.setLetterSpacing(QFont.SpacingType.AbsoluteSpacing, 1.6)
title.setFont(f)
tb.addWidget(title)
tb.addSeparator()
self.cmb_lang = QComboBox()
self.cmb_lang.setToolTip("Speech language archive to browse")
self.cmb_lang.activated.connect(lambda _: self.load_game(self.ws.game.root, self.cmb_lang.currentText())
if self.ws else None)
tb.addWidget(QLabel(" Language "))
tb.addWidget(self.cmb_lang)
tb.addSeparator()
self.act_revert_all = tb.addAction(icons.icon("revert"), "Revert all", self.revert_all)
self.act_revert_all.setToolTip("Put every original sound back")
tb.addAction(icons.icon("export"), "Export mod…", self.export_mod_dialog)
tb.addAction(icons.icon("import"), "Import mod…", self.import_mod_dialog)
tb.addSeparator()
tb.addAction(icons.icon("settings"), "Settings…", self.settings_dialog)
# banner for problems (changed files, missing game)
self.banner = QWidget()
self.banner.setObjectName("Banner")
self.banner.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
bl = QHBoxLayout(self.banner)
bl.setContentsMargins(8, 4, 8, 4)
warn = QLabel()
warn.setPixmap(icons.icon("warning", icons.ACCENT).pixmap(20, 20))
bl.addWidget(warn, 0, Qt.AlignmentFlag.AlignTop)
self.banner_text = QLabel()
self.banner_text.setWordWrap(True)
self.b_reapply = QPushButton("Re-apply my changes")
self.b_forget = QPushButton("Forget them")
self.b_choose = QPushButton("Choose game folder…")
self.b_reapply.clicked.connect(self.reapply_lost)
self.b_forget.clicked.connect(self.forget_problems)
self.b_choose.clicked.connect(self.choose_game)
bl.addWidget(self.banner_text, 1)
for b in (self.b_reapply, self.b_forget, self.b_choose):
bl.addWidget(b)
self.banner.hide()
# filters
side = theme.Sidebar()
side.configure(self.settings)
self.sidebar = side
side.setMinimumWidth(SIDEBAR_MIN)
side.setMaximumWidth(SIDEBAR_MAX) # opens at the maximum (wallpaper fully visible); can only shrink
fl = QFormLayout(side)
self.search = QLineEdit()
self.search.setPlaceholderText("Search names, characters, notes… (Ctrl+F)")
self.search.setClearButtonEnabled(True)
self.search.addAction(icons.icon("search", icons.MUTED), QLineEdit.ActionPosition.LeadingPosition)
self._search_timer = QTimer(self)
self._search_timer.setSingleShot(True)
self._search_timer.setInterval(120)
self._search_timer.timeout.connect(lambda: self._filter_text())
self.search.textChanged.connect(lambda _: self._search_timer.start())
self.cmb_group = QComboBox()
self.cmb_cat = QComboBox()
self.cmb_char = QComboBox()
self.cmb_char.setMaxVisibleItems(25)
self.cb_inferred = QCheckBox("Include characters mentioned in names")
self.cmb_arch = QComboBox()
self.cb_mod = QCheckBox("Modified only")
self.cb_fav = QCheckBox("Favorites only")
self.cmb_tag = QComboBox()
clear = QPushButton("Clear filters")
clear.setIcon(icons.icon("revert", icons.MUTED))
clear.clicked.connect(self.clear_filters)
fl.addRow(self.search)
fl.addRow("Group", self.cmb_group)
fl.addRow("Category", self.cmb_cat)
fl.addRow("Character", self.cmb_char)
fl.addRow(self.cb_inferred)
fl.addRow("Archive", self.cmb_arch)
fl.addRow("Tag", self.cmb_tag)
fl.addRow(self.cb_mod)
fl.addRow(self.cb_fav)
fl.addRow(clear)
self.lbl_count = QLabel()
fl.addRow(self.lbl_count)
self.cmb_group.currentTextChanged.connect(self._group_changed)
for w in (self.cmb_cat, self.cmb_arch, self.cmb_tag):
w.currentTextChanged.connect(lambda _: self._filters_changed())
self.cmb_char.currentTextChanged.connect(lambda _: self._filters_changed())
for w in (self.cb_inferred, self.cb_mod, self.cb_fav):
w.toggled.connect(lambda _: self._filters_changed())
# table
self.model = SoundModel(self)
self.proxy = SoundFilter(self)
self.proxy.setSourceModel(self.model)
self.table = QTableView()
self.table.setModel(self.proxy)
self.table.setSortingEnabled(True)
self.table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
self.table.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
self.table.verticalHeader().hide()
self.table.verticalHeader().setDefaultSectionSize(22)
self.table.setAlternatingRowColors(True)
self.table.setWordWrap(False)
h = self.table.horizontalHeader()
h.setSectionResizeMode(QHeaderView.ResizeMode.Interactive)
h.setStretchLastSection(True)
for c, w in zip(range(len(COLS)), (28, 360, 140, 200, 70, 55, 110, 75, 70, 200)):
self.table.setColumnWidth(c, w)
self.table.sortByColumn(C_NAME, Qt.SortOrder.AscendingOrder)
self.table.selectionModel().currentRowChanged.connect(self._current_changed)
self.table.clicked.connect(self._clicked)
self.table.doubleClicked.connect(lambda _: self.editor.play("orig"))
self.table.setContextMenuPolicy(Qt.ContextMenuPolicy.CustomContextMenu)
self.table.customContextMenuRequested.connect(self._context_menu)
# editor
self.editor = EditorPanel(self)
self.editor.applied.connect(self._after_change)
self.editor.reverted.connect(self._after_change)
self.editor.message.connect(lambda m: self.statusBar().showMessage(m, 8000))
scroll = QScrollArea()
scroll.setWidget(self.editor)
scroll.setWidgetResizable(True)
self.vsplit = QSplitter(Qt.Orientation.Vertical)
self.vsplit.addWidget(self.table)
self.vsplit.addWidget(scroll)
self.vsplit.setStretchFactor(0, 1) # extra height goes to the list; the editor keeps its fitted height
self.vsplit.setStretchFactor(1, 0)
self.scroll = scroll
self._fitted = False
self._user_sized = False # once the divider is dragged, keep the user's choice
self.vsplit.splitterMoved.connect(lambda *_: setattr(self, "_user_sized", True))
self.editor.installEventFilter(self)
hs = QSplitter(Qt.Orientation.Horizontal)
hs.addWidget(side)
hs.addWidget(self.vsplit)
hs.setStretchFactor(1, 1)
hs.setSizes([SIDEBAR_MAX, 100000])
central = QWidget()
cl = QVBoxLayout(central)
cl.setContentsMargins(0, 0, 0, 0)
cl.setSpacing(0)
cl.addWidget(self.banner)
cl.addWidget(hs, 1)
self.setCentralWidget(central)
self.spinner = Spinner(14)
self.statusBar().addWidget(self.spinner)
self.lbl_game = QLabel()
self.statusBar().addPermanentWidget(self.lbl_game)
sc = Qt.ShortcutContext.WindowShortcut
QShortcut(QKeySequence("Ctrl+F"), self, self.search.setFocus, context=sc)
QShortcut(QKeySequence("Ctrl+S"), self, lambda: self.editor.apply(), context=sc)
QShortcut(QKeySequence("Ctrl+O"), self, lambda: self.editor.load_file_dialog(), context=sc)
QShortcut(QKeySequence("Escape"), self, self.editor.stop, context=sc)
QShortcut(QKeySequence(Qt.Key.Key_Space), self.table, self.editor.toggle_play,
context=Qt.ShortcutContext.WidgetShortcut)
QShortcut(QKeySequence(Qt.Key.Key_F2), self.table, self.edit_notes, context=Qt.ShortcutContext.WidgetShortcut)
# --- 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)
# --- game loading -----------------------------------------------------------------------------
def show_no_game(self, msg=None):
self.banner_text.setText(msg or "Batman: Arkham City was not found automatically. Choose the game folder "
"(it contains BmGame and Binaries; on Linux usually ~/.steam/steam/steamapps/"
"common/Batman Arkham City GOTY).")
self.b_reapply.hide()
self.b_forget.hide()
self.b_choose.show()
self.banner.show()
def choose_game(self):
d = pick_game_dir(self, self.settings.get("game_dir") or "")
if d:
self.load_game(d)
def load_game(self, game_dir, language=None):
try:
game = Game(game_dir)
except GameError as e:
self.show_no_game(str(e))
return
lang = language if language in game.languages else game.language()
self.loading = True
self.statusBar().showMessage("Loading sound list…")
self.spinner.show()
store = self.settings.get("store_root")
full = self.settings.get("full_backup", True)
def work(job):
return W.Workspace(game, store, [lang, "SFX"] if "SFX" in game.archives else [lang], full_backup=full,
progress=lambda n, t: job.report("scanning %s: %d / %d" % (lang, n, t)))
def done(ws):
self.loading = False
self.spinner.hide()
self.ws = ws
self.settings["game_dir"] = game.root
self.settings["language"] = lang
self.save_settings()
self.cmb_lang.blockSignals(True)
self.cmb_lang.clear()
self.cmb_lang.addItems(game.languages)
self.cmb_lang.setCurrentText(lang)
self.cmb_lang.blockSignals(False)
self.populate()
self.banner.hide()
self.lbl_game.setText("%s • store: %s" % (game.root, ws.store.root))
self.statusBar().showMessage("Loaded %d sounds (%s)." % (len(ws.catalog.sounds), ", ".join(ws.catalog.archives)), 6000)
self.editor.refresh_devices()
self.check_problems()
def failed(e, tb):
self.loading = False
self.spinner.hide()
self.show_no_game("Could not load the game audio: %s" % e)
self.runner.start(work, done, failed, lambda m: self.statusBar().showMessage(m))
def populate(self):
ws = self.ws
self.model.reset(ws.catalog.sounds, ws.modified_keys(), self.userdata)
snd = ws.catalog.sounds
def fill(cmb, items):
cur = cmb.currentText()
cmb.blockSignals(True)
cmb.clear()
cmb.addItems([ANY] + items)
i = cmb.findText(cur)
cmb.setCurrentIndex(i if i >= 0 else 0)
cmb.blockSignals(False)
fill(self.cmb_group, [g for g in ("Speech", "Music", "Ambience", "SFX", "Unknown") if any(s.group == g for s in snd)])
self._fill_categories()
chars = sorted({c.strip() for s in snd for c in s.character.split(";") if c.strip()})
fill(self.cmb_char, chars)
fill(self.cmb_arch, list(ws.catalog.archives))
fill(self.cmb_tag, self.userdata.all_tags())
self._filters_changed()
def _fill_categories(self):
g = self.cmb_group.currentText()
cats = sorted({s.category for s in (self.ws.catalog.sounds if self.ws else []) if g == ANY or s.group == g})
cur = self.cmb_cat.currentText()
self.cmb_cat.blockSignals(True)
self.cmb_cat.clear()
self.cmb_cat.addItems([ANY] + cats)
i = self.cmb_cat.findText(cur)
self.cmb_cat.setCurrentIndex(i if i >= 0 else 0)
self.cmb_cat.blockSignals(False)
# --- filtering --------------------------------------------------------------------------------
def _val(self, cmb):
t = cmb.currentText().strip()
return None if not t or t == ANY else t
def _group_changed(self, _):
self._fill_categories()
self._filters_changed()
def _filters_changed(self):
self.proxy.set(group=self._val(self.cmb_group), category=self._val(self.cmb_cat),
character=self._val(self.cmb_char), include_inferred=self.cb_inferred.isChecked(),
archive=self._val(self.cmb_arch), modified_only=self.cb_mod.isChecked(),
favorites_only=self.cb_fav.isChecked(), tag=self._val(self.cmb_tag))
self._update_count()
def _filter_text(self):
self.proxy.set_text(self.search.text())
self._update_count()
def _update_count(self):
self.lbl_count.setText("%d of %d sounds" % (self.proxy.rowCount(), self.model.rowCount()))
def clear_filters(self):
self.search.clear()
for c in (self.cmb_group, self.cmb_cat, self.cmb_char, self.cmb_arch, self.cmb_tag):
c.setCurrentIndex(0)
for c in (self.cb_inferred, self.cb_mod, self.cb_fav):
c.setChecked(False)
self._filter_text()
# --- selection --------------------------------------------------------------------------------
def selected_sounds(self):
rows = self.table.selectionModel().selectedRows()
return [self.proxy.sound_at(i) for i in rows]
def _current_changed(self, cur, prev):
if cur.isValid():
self.editor.set_sound(self.proxy.sound_at(cur))
def _clicked(self, index):
if index.column() == C_FAV:
s = self.proxy.sound_at(index)
self.userdata.update(s.key, fav=not self.userdata.favorite(s.key))
self.model.refresh_keys()
def select_name(self, name):
"""Select a sound by name (used by tests and 'jump to')."""
for r in range(self.proxy.rowCount()):
idx = self.proxy.index(r, C_NAME)
if self.proxy.sound_at(idx).name == name:
self.table.selectionModel().setCurrentIndex(
idx, QItemSelectionModel.SelectionFlag.ClearAndSelect | QItemSelectionModel.SelectionFlag.Rows)
self.table.scrollTo(idx)
return True
return False
def _context_menu(self, pos):
sel = self.selected_sounds()
if not sel:
return
m = QMenu(self)
m.addAction("Play original", lambda: self.editor.play("orig"))
m.addAction("Notes && tags… (F2)", self.edit_notes)
m.addAction("Toggle favorite", lambda: self._toggle_fav(sel))
m.addSeparator()
m.addAction("Revert selected (%d)" % len(sel), lambda: self.revert_sounds(sel))
m.addAction("Copy name", lambda: self._copy("\n".join(s.name for s in sel)))
m.exec(self.table.viewport().mapToGlobal(pos))
def _copy(self, text):
from PySide6.QtWidgets import QApplication
QApplication.clipboard().setText(text)
def _toggle_fav(self, sounds):
on = not all(self.userdata.favorite(s.key) for s in sounds)
for s in sounds:
self.userdata.update(s.key, fav=on)
self.model.refresh_keys()
def edit_notes(self, sounds=None, note=None, tags=None):
sounds = sounds or self.selected_sounds()
if not sounds:
return
s0 = sounds[0]
if note is None:
dlg = QDialog(self)
dlg.setWindowTitle("Notes & tags - %s" % (s0.name if len(sounds) == 1 else "%d sounds" % len(sounds)))
f = QFormLayout(dlg)
en = QLineEdit(self.userdata.note(s0.key))
et = QLineEdit(", ".join(self.userdata.tags(s0.key)))
et.setPlaceholderText("comma separated, e.g. funny, todo")
f.addRow("Note", en)
f.addRow("Tags", et)
from PySide6.QtWidgets import QDialogButtonBox
bb = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel)
bb.accepted.connect(dlg.accept)
bb.rejected.connect(dlg.reject)
f.addRow(bb)
dlg.resize(460, 0)
if dlg.exec() != QDialog.DialogCode.Accepted:
return
note, tags = en.text(), [t for t in et.text().split(",")]
for s in sounds:
self.userdata.update(s.key, note=note, tags=tags)
self.model.refresh_keys()
cur = self.cmb_tag.currentText()
self.cmb_tag.blockSignals(True)
self.cmb_tag.clear()
self.cmb_tag.addItems([ANY] + self.userdata.all_tags())
self.cmb_tag.setCurrentText(cur)
self.cmb_tag.blockSignals(False)
# --- changes ----------------------------------------------------------------------------------
def _after_change(self, key=None):
self.model.refresh_keys(modified=self.ws.modified_keys())
self.proxy.refilter()
self._update_count()
def revert_sounds(self, sounds):
sounds = [s for s in sounds if self.ws.store.get(s.key)]
if not sounds:
self.statusBar().showMessage("Nothing to revert: the selected sounds are original.", 5000)
return
self._run_change("Reverting %d sound(s)…" % len(sounds), lambda job: self.ws.revert(sounds, job.report),
"Reverted %d sound(s)." % len(sounds))
def revert_all(self):
if not self.ws:
return
n = len(self.ws.store.entries)
if not n:
self.inform("Revert all", "All sounds are already original.")
return
if not self.confirm("Revert all", "Put the original back for all %d changed sound(s)?" % n):
return
self._run_change("Reverting everything…", lambda job: self.ws.revert_all(job.report),
"All sounds are original again.")
def _run_change(self, busy, fn, ok_msg):
self.statusBar().showMessage(busy)
self.spinner.show()
def done(_):
self.spinner.hide()
self.statusBar().showMessage(ok_msg, 8000)
self._after_change()
if self.editor.sound:
self.editor.set_sound(self.editor.sound)
self.check_problems()
def failed(e, tb):
self.spinner.hide()
self.inform("Error", str(e))
self.runner.start(fn, done, failed, lambda m: self.statusBar().showMessage(m))
# --- changed underneath -----------------------------------------------------------------------
def check_problems(self):
ws = self.ws
if not ws:
return
def work(job):
out = []
for key in list(ws.store.entries):
s = ws.catalog.by_key.get(key)
if s is None:
continue
st = ws.state(s)
if st in (W.LOST, W.CHANGED):
out.append((s, st))
return out
def done(problems):
self.problems = problems
if not problems:
if self.banner.isVisible() and not self.b_choose.isVisible():
self.banner.hide()
return
lost = [s.name for s, st in problems if st == W.LOST]
changed = [s.name for s, st in problems if st == W.CHANGED]
txt = []
if lost:
txt.append("%d of your changes were undone outside this editor (Steam 'verify files' or an update?): %s"
% (len(lost), ", ".join(lost[:4]) + (" …" if len(lost) > 4 else "")))
if changed:
txt.append("%d sound(s) were changed by something else since you modified them: %s. "
"Revert puts the original back." % (len(changed), ", ".join(changed[:4])))
self.banner_text.setText("\n".join(txt))
self.b_reapply.show()
self.b_forget.show()
self.b_choose.hide()
self.banner.show()
self.runner.start(work, done)
def reapply_lost(self):
ws = self.ws
items = [(s, ws.store.get_blob(ws.store.get(s.key)["applied"]), ws.store.get(s.key).get("meta", {}))
for s, st in getattr(self, "problems", []) if ws.store.get(s.key) and ws.store.get(s.key).get("applied")]
if items:
self._run_change("Re-applying…", lambda job: ws.apply(items, force=True, progress=job.report),
"Re-applied %d change(s)." % len(items))
def forget_problems(self):
if not self.confirm("Forget", "Stop tracking these sounds? Their current game files are kept as they are."):
return
for s, _ in getattr(self, "problems", []):
self.ws.store.forget(s.key)
self.ws.store.save()
self.ws.store.gc()
self.banner.hide()
self._after_change()
# --- mods -------------------------------------------------------------------------------------
def export_mod_dialog(self, path=None, keys=None, title="", author="", description=""):
if not self.ws:
return
mods = self.ws.modified_keys()
if not mods:
self.inform("Export mod", "You have not changed any sound yet.")
return
if keys is None:
names = [(k, self.ws.catalog.by_key[k].name if k in self.ws.catalog.by_key else k) for k in mods]
dlg = ExportModDialog(names, self)
if dlg.exec() != QDialog.DialogCode.Accepted:
return
keys, title, author, description = (dlg.selected_keys(), dlg.title.text(), dlg.author.text(),
dlg.desc.toPlainText())
path, _ = QFileDialog.getSaveFileName(self, "Save mod package", os.path.join(
self.settings.get("last_dir") or "", (title or "my_mod") + ".acmod"), "Unheard City mod (*.acmod)")
if not path:
return
self._run_change("Exporting…", lambda job: export_mod(self.ws, path, keys, title, author, description),
"Saved %s" % path)
def import_mod_dialog(self, path=None, confirm=True):
if not self.ws:
return
if path is None:
path, _ = QFileDialog.getOpenFileName(self, "Install mod package", self.settings.get("last_dir") or "",
"Unheard City mod (*.acmod)")
if not path:
return
try:
man, items = match_mod(self.ws, path)
except Exception as e: # noqa: BLE001
self.inform("Import mod", "Cannot read this package: %s" % e)
return
if confirm and ImportModDialog(man, items, self).exec() != QDialog.DialogCode.Accepted:
return
ws = self.ws
def work(job):
prepare_import(ws, path, items, progress=job.report)
changes = [(im.sound, im.wem, {"source": "mod:" + os.path.basename(path)}) for im in items if im.wem]
try:
ws.apply(changes, progress=job.report)
except W.ConflictError:
ws.apply(changes, force=True, progress=job.report)
return len(changes)
self._run_change("Installing mod…", work, "Installed %s" % os.path.basename(path))
# --- settings ---------------------------------------------------------------------------------
def settings_dialog(self):
dlg = SettingsDialog(self.settings, self)
if dlg.exec() != QDialog.DialogCode.Accepted:
return
old = dict(self.settings)
self.settings.update(dlg.values())
self.save_settings()
self._apply_output_device()
self.sidebar.configure(self.settings)
if (self.settings.get("game_dir") != old.get("game_dir") or self.settings.get("store_root") != old.get("store_root")
or self.settings.get("full_backup") != old.get("full_backup")):
if self.settings.get("game_dir"):
self.load_game(self.settings["game_dir"], self.settings.get("language"))
def _apply_output_device(self):
name = self.settings.get("output_device")
self.player.set_device(find_device(output_devices(), name) if name else None)
def save_settings(self):
self.settings["window_geometry"] = bytes(self.saveGeometry().toHex()).decode()
try:
settings_mod.save(self.settings)
except OSError:
pass
def fit_editor(self):
"""Give the editor panel the height of its contents (everything visible), the list gets the rest."""
total = sum(self.vsplit.sizes()) or self.vsplit.height()
lay = self.editor.layout()
need = self.editor.sizeHint().height()
if lay.hasHeightForWidth(): # wrapped labels: height at the actual width
need = max(need, lay.heightForWidth(self.scroll.viewport().width()))
need += 2 * self.scroll.frameWidth()
if self.editor.minimumSizeHint().width() > self.scroll.viewport().width(): # narrow window: room for the h-bar
need += self.scroll.horizontalScrollBar().sizeHint().height()
editor_h = min(need, max(0, total - TABLE_MIN))
self.vsplit.setSizes([total - editor_h, editor_h])
def eventFilter(self, obj, e):
if obj is self.editor and e.type() == QEvent.Type.LayoutRequest and self._fitted and not self._user_sized:
QTimer.singleShot(0, self.fit_editor)
return super().eventFilter(obj, e)
def showEvent(self, e):
super().showEvent(e)
if not self._fitted:
self._fitted = True
QTimer.singleShot(0, self.fit_editor)
def closeEvent(self, e):
self.player.stop()
self.save_settings()
super().closeEvent(e)

184
app/models.py Normal file
View File

@@ -0,0 +1,184 @@
"""Table model for the sound browser + a combined filter (search text and facet filters)."""
from PySide6.QtCore import QAbstractTableModel, QModelIndex, QSortFilterProxyModel, Qt
from PySide6.QtGui import QFont, QPalette
from PySide6.QtWidgets import QApplication
from . import icons
COLS = ["", "Name", "Category", "Character", "Length", "Archive", "Format", "Max size", "Status", "Notes / tags"]
C_FAV, C_NAME, C_CAT, C_CHAR, C_LEN, C_ARCH, C_FMT, C_SIZE, C_STATUS, C_NOTES = range(len(COLS))
SORT_ROLE = Qt.ItemDataRole.UserRole + 1
SOUND_ROLE = Qt.ItemDataRole.UserRole + 2
class SoundModel(QAbstractTableModel):
def __init__(self, parent=None):
super().__init__(parent)
self.sounds = []
self.modified = set()
self.userdata = None
self.hay = []
def reset(self, sounds, modified, userdata):
self.beginResetModel()
self.sounds, self.modified, self.userdata = list(sounds), set(modified), userdata
self._build_hay()
self.endResetModel()
def _build_hay(self):
ud = self.userdata
self.hay = ["\n".join((s.name, s.category, s.character, s.characters_inferred,
ud.note(s.key) if ud else "", " ".join(ud.tags(s.key)) if ud else "")).lower()
for s in self.sounds]
self.ids = [s.target.replace(":", " ").replace("/", " ").lower() for s in self.sounds]
def refresh_keys(self, keys=None, modified=None):
"""Update status / notes of some rows (all if keys is None)."""
if modified is not None:
self.modified = set(modified)
self._build_hay()
if self.sounds:
self.dataChanged.emit(self.index(0, 0), self.index(len(self.sounds) - 1, len(COLS) - 1))
def rowCount(self, parent=QModelIndex()):
return 0 if parent.isValid() else len(self.sounds)
def columnCount(self, parent=QModelIndex()):
return len(COLS)
def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole):
if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole:
return COLS[section]
if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DecorationRole and section == C_FAV:
return icons.icon("star", icons.ACCENT)
if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.ToolTipRole:
return {C_FAV: "Favorite", C_CHAR: "Verified speaker; ~grey = characters mentioned by the name",
C_SIZE: "Largest replacement that fits (speech/effects keep the original's size)",
C_STATUS: "Modified = contains your replacement"}.get(section)
return None
def data(self, index, role=Qt.ItemDataRole.DisplayRole):
if not index.isValid():
return None
s = self.sounds[index.row()]
c = index.column()
ud = self.userdata
if role == SOUND_ROLE:
return s
if role == Qt.ItemDataRole.DisplayRole:
if c == C_FAV:
return ""
if c == C_NAME:
return s.name
if c == C_CAT:
return s.category
if c == C_CHAR:
return s.character or (("~ " + s.characters_inferred) if s.characters_inferred else "")
if c == C_LEN:
return "%.2f s" % s.seconds
if c == C_ARCH:
return s.archive
if c == C_FMT:
return "%d Hz %s" % (s.rate, {1: "mono", 2: "stereo"}.get(s.channels, "%d ch" % s.channels))
if c == C_SIZE:
return "any" if s.is_pack_media else "%.1f KB" % (s.size / 1024.0)
if c == C_STATUS:
return "Modified" if s.key in self.modified else ""
if c == C_NOTES:
if not ud:
return ""
t = ud.tags(s.key)
return " ".join("#" + x for x in t) + (" " if t else "") + ud.note(s.key)
if role == SORT_ROLE:
if c == C_FAV:
return 0 if ud and ud.favorite(s.key) else 1
if c == C_LEN:
return s.seconds
if c == C_SIZE:
return 1e12 if s.is_pack_media else s.size
if c == C_FMT:
return (s.rate, s.channels)
if c == C_STATUS:
return 0 if s.key in self.modified else 1
return (self.data(index, Qt.ItemDataRole.DisplayRole) or "").lower()
if role == Qt.ItemDataRole.DecorationRole and c == C_FAV:
fav = bool(ud and ud.favorite(s.key))
return icons.icon("star" if fav else "star-off", icons.ACCENT if fav else "#4a4a4a")
if role == Qt.ItemDataRole.DecorationRole and c == C_STATUS and s.key in self.modified:
return icons.icon("apply", icons.ACCENT)
if role == Qt.ItemDataRole.ForegroundRole and c == C_CHAR and not s.character:
return QApplication.palette().color(QPalette.ColorRole.PlaceholderText)
if role == Qt.ItemDataRole.FontRole and s.key in self.modified and c in (C_NAME, C_STATUS):
f = QFont()
f.setBold(True)
return f
if role == Qt.ItemDataRole.ToolTipRole and c in (C_NAME, C_CHAR):
tip = "%s\n%s %s" % (s.name, s.key, s.group + " / " + s.category)
if s.character:
tip += "\nSpeaker: " + s.character
if s.characters_inferred:
tip += "\nMentioned in the name: " + s.characters_inferred
return tip
return None
class SoundFilter(QSortFilterProxyModel):
def __init__(self, parent=None):
super().__init__(parent)
self.words = []
self.group = None
self.category = None
self.character = None
self.include_inferred = False
self.archive = None
self.modified_only = False
self.favorites_only = False
self.tag = None
self.setSortRole(SORT_ROLE)
self.setDynamicSortFilter(False)
def refilter(self):
if hasattr(self, "beginFilterChange"): # Qt >= 6.10
self.beginFilterChange()
self.endFilterChange(QSortFilterProxyModel.Direction.Rows)
else:
self.invalidateRowsFilter()
def set(self, **kw):
for k, v in kw.items():
setattr(self, k, v)
self.refilter()
def set_text(self, text):
self.words = [w for w in text.lower().replace("_", " ").split() if w]
self.refilter()
def filterAcceptsRow(self, row, parent):
m = self.sourceModel()
s = m.sounds[row]
if self.group and s.group != self.group:
return False
if self.category and s.category != self.category:
return False
if self.archive and s.archive != self.archive:
return False
if self.character:
c = self.character.lower()
if c not in s.character.lower() and not (self.include_inferred and c in s.characters_inferred.lower()):
return False
if self.modified_only and s.key not in m.modified:
return False
ud = m.userdata
if self.favorites_only and not (ud and ud.favorite(s.key)):
return False
if self.tag and not (ud and self.tag in ud.tags(s.key)):
return False
if self.words:
h = m.hay[row]
for w in self.words:
if w not in h and not (len(w) >= 6 and w in m.ids[row]): # IDs like b49cdf48 also work
return False
return True
def sound_at(self, proxy_index):
return self.sourceModel().sounds[self.mapToSource(proxy_index).row()]

53
app/spinner.py Normal file
View File

@@ -0,0 +1,53 @@
"""Small activity spinner: a rotating accent-coloured arc. Runs only while visible."""
from PySide6.QtCore import QRectF, QTimer, Qt
from PySide6.QtGui import QColor, QPainter, QPen
from PySide6.QtWidgets import QWidget
from . import icons
STEP_MS = 16
STEP_DEG = 9
def paint_arc(p, rect, angle, color=icons.ACCENT):
"""Draw the spinner (faint ring + 100 degree arc) into rect; used by Spinner and the waveform overlay."""
w = max(1.5, rect.width() / 8)
r = rect.adjusted(w / 2, w / 2, -w / 2, -w / 2)
p.save()
p.setRenderHint(QPainter.RenderHint.Antialiasing)
ring = QColor(color)
ring.setAlpha(45)
p.setPen(QPen(ring, w))
p.drawEllipse(r)
pen = QPen(QColor(color), w)
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
p.setPen(pen)
p.drawArc(r, int(-angle * 16), 100 * 16)
p.restore()
class Spinner(QWidget):
def __init__(self, size=16, parent=None):
super().__init__(parent)
self.setFixedSize(size, size)
self.angle = 0
self.timer = QTimer(self)
self.timer.setInterval(STEP_MS)
self.timer.timeout.connect(self._step)
self.hide()
def _step(self):
self.angle = (self.angle + STEP_DEG) % 360
self.update()
def showEvent(self, e):
self.timer.start()
super().showEvent(e)
def hideEvent(self, e):
self.timer.stop()
super().hideEvent(e)
def paintEvent(self, _):
p = QPainter(self)
paint_arc(p, QRectF(self.rect()), self.angle)

140
app/theme.py Normal file
View File

@@ -0,0 +1,140 @@
"""The app look: neutral dark greys, one dark red accent, the wallpaper behind the filter panel.
`apply(app)` sets the style for the whole application; `Sidebar` is the filter panel, painted over the wallpaper
(app/assets/wallpaper.jpg) when the background is on.
"""
from PySide6.QtCore import Qt
from PySide6.QtGui import QColor, QImage, QPainter, QPalette
from PySide6.QtWidgets import QWidget
from . import icons
ACCENT = "#b0242c"
ACCENT_HI = "#cf3a42"
STEEL = "#c4c4c4" # the original waveform: neutral light grey
TEXT = "#e6e6e6"
MUTED = "#9a9a9a"
BG = "#141414" # dialogs
PANEL_BG = "#0c0c0c" # main window: behind the filter and editor panels (darker for contrast)
SURFACE = "#1b1b1b"
BORDER = "#2c2c2c"
CONTROL = "#3a3a3a"
QSS = """
QWidget { color: %(TEXT)s; }
QMainWindow { background: %(PANEL_BG)s; }
QDialog { background: %(BG)s; }
QToolTip { background: #202020; color: %(TEXT)s; border: 1px solid %(CONTROL)s; padding: 4px; }
QToolBar { background: #0f0f0f; border: none; border-bottom: 1px solid %(BORDER)s; spacing: 6px; padding: 4px 6px; }
QToolBar QToolButton { color: %(TEXT)s; padding: 4px 9px; border-radius: 4px; }
QToolBar QToolButton:hover { background: #262626; }
QLabel#AppTitle { color: %(TEXT)s; font-weight: bold; padding: 0 10px 0 2px; }
QStatusBar { background: #0f0f0f; color: %(MUTED)s; border-top: 1px solid %(BORDER)s; }
QStatusBar QLabel { color: %(MUTED)s; }
QWidget#Sidebar { background: transparent; border-right: 1px solid %(BORDER)s; }
QWidget#EditorPanel, QScrollArea, QScrollArea > QWidget > QWidget { background: transparent; }
QWidget#Banner { background: #3a1416; border-bottom: 1px solid %(ACCENT)s; }
QLabel#EditorTitle { color: %(TEXT)s; }
QLabel#Muted { color: %(MUTED)s; }
QTableView { background: %(SURFACE)s; alternate-background-color: #202020; gridline-color: %(BORDER)s;
border: 1px solid %(BORDER)s; selection-background-color: rgba(176,36,44,70); selection-color: #ffffff; }
QTableView::item { padding: 0 4px; }
QHeaderView::section { background: #171717; color: %(MUTED)s; border: none; border-right: 1px solid %(BORDER)s;
border-bottom: 1px solid %(CONTROL)s; padding: 4px 6px; font-weight: bold; }
QTableCornerButton::section { background: #171717; border: none; }
QGroupBox { background: %(SURFACE)s; border: 1px solid %(BORDER)s; border-radius: 6px;
margin-top: 16px; padding: 10px 6px 6px 6px; }
QGroupBox::title { subcontrol-origin: margin; left: 12px; padding: 0 6px; color: %(MUTED)s; font-weight: bold; }
QPushButton { background: #242424; border: 1px solid %(CONTROL)s; border-radius: 4px; padding: 5px 12px; }
QPushButton:hover { background: #2c2c2c; border-color: #4a4a4a; }
QPushButton:pressed, QPushButton:checked { background: rgba(176,36,44,45); border-color: %(ACCENT)s; }
QPushButton:disabled { color: #5c5c5c; border-color: #2a2a2a; background: #1c1c1c; }
QPushButton#Primary { background: %(ACCENT)s; color: #ffffff; font-weight: bold; border: 1px solid %(ACCENT)s; }
QPushButton#Primary:hover { background: %(ACCENT_HI)s; border-color: %(ACCENT_HI)s; }
QPushButton#Primary:disabled { background: #3a1c1e; color: #8a6466; border-color: #3a1c1e; }
QLineEdit, QComboBox, QDoubleSpinBox, QPlainTextEdit, QListWidget, QTreeWidget {
background: #101010; border: 1px solid %(CONTROL)s; border-radius: 4px; padding: 3px 6px;
selection-background-color: rgba(176,36,44,120); }
QLineEdit:focus, QComboBox:focus, QDoubleSpinBox:focus, QPlainTextEdit:focus { border-color: %(ACCENT)s; }
QComboBox QAbstractItemView { background: #1b1b1b; border: 1px solid %(CONTROL)s;
selection-background-color: rgba(176,36,44,90); }
QCheckBox::indicator { width: 14px; height: 14px; border: 1px solid #555555; border-radius: 3px; background: #101010; }
QCheckBox::indicator:checked { background: %(ACCENT)s; border-color: %(ACCENT)s; }
QProgressBar { background: #101010; border: 1px solid %(CONTROL)s; border-radius: 3px; height: 10px; }
QProgressBar::chunk { background: %(ACCENT)s; border-radius: 2px; }
QSlider::groove:horizontal { height: 4px; background: #333333; border-radius: 2px; }
QSlider::sub-page:horizontal { background: %(ACCENT)s; border-radius: 2px; }
QSlider::handle:horizontal { background: %(TEXT)s; width: 12px; margin: -5px 0; border-radius: 6px; }
QSplitter::handle { background: %(BORDER)s; }
QScrollBar:vertical, QScrollBar:horizontal { background: transparent; border: none; margin: 0; }
QScrollBar:vertical { width: 10px; } QScrollBar:horizontal { height: 10px; }
QScrollBar::handle { background: #3c3c3c; border-radius: 4px; min-height: 24px; min-width: 24px; }
QScrollBar::handle:hover { background: #555555; }
QScrollBar::add-line, QScrollBar::sub-line { width: 0; height: 0; }
QMenu { background: #1b1b1b; border: 1px solid %(CONTROL)s; }
QMenu::item { padding: 5px 22px 5px 10px; }
QMenu::item:selected { background: rgba(176,36,44,70); }
""" % dict(TEXT=TEXT, BG=BG, PANEL_BG=PANEL_BG, ACCENT=ACCENT, ACCENT_HI=ACCENT_HI, MUTED=MUTED, BORDER=BORDER, SURFACE=SURFACE,
CONTROL=CONTROL)
# waveform colours: original / installed / replacement
WAVE = {"orig": QColor(STEEL), "inst": QColor("#8f4a4e"), "new": QColor(ACCENT)}
WAVE_BG = QColor("#111111")
SIDEBAR_VEIL = QColor(16, 16, 16) # alpha follows the background strength
def apply(app):
app.setStyle("Fusion")
pal = QPalette()
for role, col in [(QPalette.ColorRole.Window, BG), (QPalette.ColorRole.WindowText, TEXT),
(QPalette.ColorRole.Base, "#101010"), (QPalette.ColorRole.AlternateBase, "#202020"),
(QPalette.ColorRole.Text, TEXT), (QPalette.ColorRole.Button, "#242424"),
(QPalette.ColorRole.ButtonText, TEXT), (QPalette.ColorRole.Highlight, ACCENT),
(QPalette.ColorRole.HighlightedText, "#ffffff"), (QPalette.ColorRole.ToolTipBase, "#202020"),
(QPalette.ColorRole.ToolTipText, TEXT), (QPalette.ColorRole.PlaceholderText, MUTED),
(QPalette.ColorRole.Mid, "#333333"), (QPalette.ColorRole.Link, ACCENT)]:
pal.setColor(role, QColor(col))
pal.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.ButtonText, QColor("#5c5c5c"))
pal.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Text, QColor("#5c5c5c"))
app.setPalette(pal)
app.setStyleSheet(QSS)
def wallpaper():
"""The Arkham City wallpaper (app/assets/wallpaper.jpg) as a QImage, or None if the assets are missing."""
p = icons.asset("wallpaper.jpg")
img = QImage(p) if p else QImage()
return None if img.isNull() else img
class Sidebar(QWidget):
"""Filter panel with the wallpaper behind it: scaled to the panel height, its left part fills the panel."""
def __init__(self, parent=None):
super().__init__(parent)
self.setObjectName("Sidebar")
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
self.image = None
self.opacity = 0.1
self._scaled = None
def configure(self, settings):
st = settings or {}
self.image = wallpaper() if st.get("background_enabled", True) else None
self.opacity = max(0.0, min(1.0, float(st.get("background_opacity", 0.1))))
self._scaled = None
self.update()
def paintEvent(self, e):
if self.image is not None:
if self._scaled is None or self._scaled.height() != self.height():
self._scaled = self.image.scaledToHeight(self.height(), Qt.TransformationMode.SmoothTransformation)
p = QPainter(self)
p.setOpacity(self.opacity)
p.drawImage(0, 0, self._scaled)
p.setOpacity(1.0)
veil = QColor(SIDEBAR_VEIL)
veil.setAlpha(int(180 * self.opacity)) # stronger image, stronger veil: labels stay readable
p.fillRect(self.rect(), veil)
p.end()
super().paintEvent(e)

50
app/userdata.py Normal file
View File

@@ -0,0 +1,50 @@
"""Per-user notes, tags and favorites for sounds (user data folder, never the game folder)."""
import json
import os
from ackit.paths import user_dir
class UserData:
def __init__(self, path=None):
self.path = path or os.path.join(user_dir("data"), "sound_notes.json")
try:
self.data = json.load(open(self.path, encoding="utf-8"))
except (OSError, ValueError):
self.data = {}
def save(self):
tmp = self.path + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(self.data, f, indent=1, ensure_ascii=False)
os.replace(tmp, self.path)
def _get(self, key):
return self.data.get(key, {})
def note(self, key):
return self._get(key).get("note", "")
def tags(self, key):
return self._get(key).get("tags", [])
def favorite(self, key):
return bool(self._get(key).get("fav"))
def update(self, key, note=None, tags=None, fav=None):
d = dict(self._get(key))
if note is not None:
d["note"] = note.strip()
if tags is not None:
d["tags"] = sorted({t.strip() for t in tags if t.strip()})
if fav is not None:
d["fav"] = bool(fav)
d = {k: v for k, v in d.items() if v}
if d:
self.data[key] = d
else:
self.data.pop(key, None)
self.save()
def all_tags(self):
return sorted({t for d in self.data.values() for t in d.get("tags", [])})

123
app/waveform.py Normal file
View File

@@ -0,0 +1,123 @@
"""Waveform display: envelope, playhead, click-to-seek, drag-to-select (trim region)."""
import numpy as np
from PySide6.QtCore import QPointF, QRectF, Qt, QTimer, Signal
from PySide6.QtGui import QColor, QPainter, QPalette, QPen
from PySide6.QtWidgets import QSizePolicy, QWidget
from . import spinner
class Waveform(QWidget):
seekRequested = Signal(float) # seconds
selectionChanged = Signal(float, float) # seconds (start, end); (0, 0) = none
def __init__(self, parent=None, selectable=False):
super().__init__(parent)
self.setMinimumHeight(70)
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
self.selectable = selectable
self.env = None # (mins, maxs) at a fixed resolution
self.duration = 0.0
self.playhead = None
self.sel = None
self._drag = None
self.placeholder = "no audio"
self.accent = None
self.bg = None # background colour (theme), else the palette's Base
self.busy = False # spinner beside the placeholder (audio being loaded)
self._angle = 0
self._timer = QTimer(self)
self._timer.setInterval(spinner.STEP_MS)
self._timer.timeout.connect(self._spin)
def _spin(self):
self._angle = (self._angle + spinner.STEP_DEG) % 360
self.update()
def set_audio(self, x, rate, placeholder="no audio", busy=False):
self.placeholder = placeholder
self.busy = busy and (x is None or len(x) == 0)
self._timer.start() if self.busy else self._timer.stop()
self.sel = None
self.playhead = None
if x is None or len(x) == 0:
self.env, self.duration = None, 0.0
else:
m = x.mean(axis=1) if x.ndim == 2 else x
cols = 2000
n = len(m)
edges = np.linspace(0, n, cols + 1).astype(int)
mins = np.array([m[a:b].min() if b > a else 0 for a, b in zip(edges[:-1], edges[1:])])
maxs = np.array([m[a:b].max() if b > a else 0 for a, b in zip(edges[:-1], edges[1:])])
self.env = (mins, maxs)
self.duration = n / float(rate)
self.update()
def set_playhead(self, seconds):
self.playhead = seconds
self.update()
def set_selection(self, a, b):
self.sel = (min(a, b), max(a, b)) if b != a else None
self.update()
def _x2t(self, x):
return max(0.0, min(self.duration, x / max(1, self.width()) * self.duration))
def _t2x(self, t):
return t / self.duration * self.width() if self.duration else 0
def mousePressEvent(self, e):
if not self.duration:
return
t = self._x2t(e.position().x())
if self.selectable and e.button() == Qt.MouseButton.LeftButton and e.modifiers() & Qt.KeyboardModifier.ShiftModifier:
self._drag = t
elif self.selectable and e.button() == Qt.MouseButton.RightButton:
self._drag = t
else:
self.seekRequested.emit(t)
def mouseMoveEvent(self, e):
if self._drag is not None:
self.set_selection(self._drag, self._x2t(e.position().x()))
def mouseReleaseEvent(self, e):
if self._drag is not None:
self._drag = None
a, b = self.sel if self.sel else (0.0, 0.0)
self.selectionChanged.emit(a, b)
def paintEvent(self, _):
p = QPainter(self)
pal = self.palette()
r = self.rect()
p.fillRect(r, self.bg or pal.color(QPalette.ColorRole.Base))
mid = r.height() / 2
fg = self.accent or pal.color(QPalette.ColorRole.Highlight)
if self.env is None:
p.setPen(pal.color(QPalette.ColorRole.PlaceholderText))
p.drawText(r, Qt.AlignmentFlag.AlignCenter, self.placeholder)
if self.busy:
tw = p.fontMetrics().horizontalAdvance(self.placeholder)
d = 14
spinner.paint_arc(p, QRectF(r.center().x() - tw / 2 - d - 8, mid - d / 2, d, d), self._angle)
return
if self.sel:
sc = QColor(fg)
sc.setAlpha(50)
p.fillRect(QRectF(self._t2x(self.sel[0]), 0, self._t2x(self.sel[1]) - self._t2x(self.sel[0]), r.height()), sc)
mins, maxs = self.env
w = max(1, r.width())
idx = (np.arange(w) * len(mins) / w).astype(int)
p.setPen(QPen(fg, 1))
for x, i in enumerate(idx):
j = min(len(mins), int((x + 1) * len(mins) / w) + 1)
lo, hi = mins[i:j].min(), maxs[i:j].max()
p.drawLine(QPointF(x, mid - hi * (mid - 2)), QPointF(x, mid - lo * (mid - 2)))
p.setPen(QPen(pal.color(QPalette.ColorRole.Mid), 1))
p.drawLine(0, int(mid), w, int(mid))
if self.playhead is not None:
p.setPen(QPen(pal.color(QPalette.ColorRole.Text), 1))
x = self._t2x(self.playhead)
p.drawLine(QPointF(x, 0), QPointF(x, r.height()))

72
app/workers.py Normal file
View File

@@ -0,0 +1,72 @@
"""Run long operations off the GUI thread, with progress messages and cancel."""
import traceback
from PySide6.QtCore import QObject, QRunnable, QThreadPool, Signal
class Cancelled(Exception):
pass
class _Signals(QObject):
done = Signal(object)
failed = Signal(object, str)
progress = Signal(str)
class Job(QRunnable):
"""fn(job) -> result. Inside fn call job.report(msg) and job.check() (raises Cancelled)."""
def __init__(self, fn):
super().__init__()
self.fn = fn
self.signals = _Signals()
self.cancelled = False
def report(self, msg):
self.check()
self.signals.progress.emit(str(msg))
def check(self):
if self.cancelled:
raise Cancelled()
def cancel(self):
self.cancelled = True
def run(self):
try:
r = self.fn(self)
except Cancelled:
self.signals.failed.emit(Cancelled(), "cancelled")
except Exception as e: # noqa: BLE001 - shown to the user
self.signals.failed.emit(e, traceback.format_exc())
else:
self.signals.done.emit(r)
class Runner:
"""Starts jobs on a thread pool (or inline when `sync`, used by the GUI tests)."""
def __init__(self, sync=False):
self.sync = sync
self.pool = QThreadPool.globalInstance()
self.active = set() # keep Python references until the queued result signal is delivered
def start(self, fn, on_done=None, on_failed=None, on_progress=None):
job = Job(fn)
job.setAutoDelete(False)
self.active.add(job)
job.signals.done.connect(lambda *_: self.active.discard(job))
job.signals.failed.connect(lambda *_: self.active.discard(job))
if on_done:
job.signals.done.connect(on_done)
if on_failed:
job.signals.failed.connect(on_failed)
if on_progress:
job.signals.progress.connect(on_progress)
if self.sync:
job.run()
else:
self.pool.start(job)
return job