Initial commit
This commit is contained in:
1
app/__init__.py
Normal file
1
app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Unheard City (PySide6 GUI on top of ackit)."""
|
||||
250
app/audioio.py
Normal file
250
app/audioio.py
Normal 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
163
app/dialogs.py
Normal 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
526
app/editor.py
Normal 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
187
app/icons.py
Normal 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
91
app/main.py
Normal 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
640
app/mainwindow.py
Normal 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
184
app/models.py
Normal 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
53
app/spinner.py
Normal 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
140
app/theme.py
Normal 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
50
app/userdata.py
Normal 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
123
app/waveform.py
Normal 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
72
app/workers.py
Normal 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
|
||||
Reference in New Issue
Block a user