Version 3.4.0: Edit tab (edit list, large subtitle, replacements with effects and presets), Sounds tab only browses, ackit fx (pedalboard + own reverb) and replace --fx, first Linux build, catalog without a mod package

This commit is contained in:
2026-10-28 13:54:12 +01:00
parent a706dae419
commit 6de470174e
36 changed files with 2872 additions and 278 deletions

View File

@@ -2,6 +2,7 @@
import hashlib
import os
import numpy as np
import pytest
pytest.importorskip("PySide6")
@@ -53,6 +54,14 @@ def type_search(w, text):
w._filter_text()
def edit(w, name):
"""Select a sound in the Sounds tab and open it in the Edit tab (what Enter / the Edit button do)."""
assert w.select_name(name)
w.edit_selected()
assert w.tabs.currentWidget() is w.edit and w.editor.sound.name == name
return w.editor
def test_browse_search_filter(win):
w, root, _ = win
assert w.model.rowCount() == len(w.ws.catalog.sounds)
@@ -92,9 +101,8 @@ def test_replace_apply_revert_byte_identical(win, tmp_path):
w, root, originals = win
before = files(root)
type_search(w, "Speech_Synth_Radio_02")
assert w.select_name("Speech_Synth_Radio_02")
ed = w.editor
assert ed.sound.name == "Speech_Synth_Radio_02" and ed.orig is not None
ed = edit(w, "Speech_Synth_Radio_02")
assert ed.orig is not None
wav = str(tmp_path / "new.wav")
x, sr = synth.tone(1.0, 44100, 990)
audio.save_wav(wav, x, sr)
@@ -120,8 +128,7 @@ def test_bank_sound_apply_revert_in_editor(win, tmp_path):
before = files(root)
w.cmb_arch.setCurrentText("BANK")
assert w.proxy.rowCount() == 7 # 6 neutral + the INT copy of a localized line
assert w.select_name("CHR_SynthFoley_1")
ed = w.editor
ed = edit(w, "CHR_SynthFoley_1")
assert "bank CHR_SynthFoley (2 copies)" in ed.meta.text()
wav = str(tmp_path / "foley.wav")
audio.save_wav(wav, *synth.tone(0.2, 44100, 1300))
@@ -151,7 +158,9 @@ def test_subtitles_tab_edit_and_link(win):
key = p.current.key
assert w.ws.texts.state(key) == "modified" and p.model.modified[key] == "Scan two."
p.go_to_sound.emit(p.current.sound) # "Go to the sound"
assert w.tabs.currentIndex() == 0 and w.editor.sound.name == "Speech_Synth_Radio_02"
assert w.tabs.currentIndex() == 0 and w.preview.sound.name == "Speech_Synth_Radio_02"
assert "Scan two." in w.preview.sub.text() # the browser shows it, read-only
w.edit_selected()
box = w.editor.subtitle
assert [e.text() for e in box.edits] == ["Scan two.", "Second part."]
box.edits[1].setText("Part two.")
@@ -173,7 +182,7 @@ def test_one_language_for_everything(win):
w.tabs.setCurrentWidget(w.subs)
assert w.subs.lang == "RUS" and "RUS" in w.subs.lbl_lang.text()
w.tabs.setCurrentIndex(0)
w.select_name("Speech_Synth_Radio_02")
edit(w, "Speech_Synth_Radio_02")
assert [e.text() for e in w.editor.subtitle.edits] == ["Строка два"]
w.tabs.setCurrentWidget(w.menu)
assert w.menu.lang is None and w.menu.model.rowCount() == 0 # no RUS menu files in the synthetic game
@@ -293,8 +302,7 @@ def test_text_undone_outside_shows_banner(win):
def test_record_simulated_input(win):
w, root, _ = win
w.select_name("CHR_RadioScanner_1")
ed = w.editor
ed = edit(w, "CHR_RadioScanner_1")
x, sr = synth.tone(0.5, 48000, 500)
ed.recorder.start_simulated(x, sr, realtime=True)
assert ed.recorder.is_recording()
@@ -327,7 +335,7 @@ def test_notes_tags_favorites(win):
def test_mod_export_import(win, tmp_path):
w, root, _ = win
w.select_name("Speech_Synth_Radio_02")
edit(w, "Speech_Synth_Radio_02")
x, sr = synth.tone(1.0, 24000, 1200)
w.editor.set_source(x, sr, "tone")
w.editor.apply()
@@ -344,7 +352,7 @@ def test_mod_export_import(win, tmp_path):
def test_changed_underneath_banner(win):
w, root, originals = win
w.select_name("CHR_RadioScanner_1")
edit(w, "CHR_RadioScanner_1")
x, sr = synth.tone(0.5, 24000, 1500)
w.editor.set_source(x, sr, "tone")
w.editor.apply()
@@ -377,7 +385,7 @@ def test_selection_survives_sort_and_filter(win):
from app.models import C_LEN
w, _, _ = win
assert w.select_name("Speech_Synth_Radio_02")
s = w.editor.sound
s = w.preview.sound
w.table.sortByColumn(C_LEN, Qt.SortOrder.DescendingOrder)
type_search(w, "radio")
cur = w.table.currentIndex()
@@ -409,9 +417,13 @@ def test_spinners_while_loading(win, tmp_path):
w, root, _ = win
held = w.runner = HeldRunner(w.runner)
w.select_name("CHR_RadioScanner_1")
assert w.editor.wf_orig.busy and w.editor.wf_orig._timer.isActive()
assert w.preview.wf_orig.busy and w.preview.wf_orig._timer.isActive()
held.release()
assert not w.editor.wf_orig.busy and not w.editor.wf_orig._timer.isActive()
assert not w.preview.wf_orig.busy and not w.preview.wf_orig._timer.isActive()
w.edit_selected()
assert w.editor.wf_orig.busy
held.release()
assert not w.editor.wf_orig.busy
wav = str(tmp_path / "a.wav")
audio.save_wav(wav, *synth.tone(0.3, 22050, 700))
w.editor.load_file(wav)
@@ -433,14 +445,24 @@ def test_background_toggle_and_filters_look_alike(win):
assert not any(c.isEditable() for c in (w.cmb_group, w.cmb_cat, w.cmb_char, w.cmb_arch, w.cmb_tag))
def test_editor_panel_fits_its_contents(win):
def test_browser_only_browses_edit_tab_fits(win):
"""The Sounds tab has no editing tools (player + Edit); the Edit tab needs no scrolling at 1400 x 900 and
1280 x 800, even for a speech line with a two-part subtitle."""
from PySide6.QtWidgets import QPushButton
w, root, _ = win
w.resize(1400, 900)
texts = {b.text() for b in w.vsplit.findChildren(QPushButton)}
assert {"Edit", "Add to edit list", "Play original", "Play installed", "Stop"} <= texts
assert not texts & {"Record", "Apply to game", "Load file…", "Revert"}
w.show()
QTest.qWait(100)
need = w.editor.sizeHint().height()
assert w.scroll.viewport().height() >= need - 2, (w.scroll.viewport().height(), need)
assert w.vsplit.sizes()[0] >= 160
for size in ((1400, 900), (1280, 800)):
w.resize(*size)
edit(w, "Speech_Synth_Radio_02") # two subtitle parts: the tallest case
QTest.qWait(100)
assert w.editor.subtitle_box.isVisible() and len(w.editor.subtitle.edits) == 2
for area in (w.editor.parentWidget().parentWidget(), w.edit.rack.parentWidget().parentWidget()):
assert not area.verticalScrollBar().isVisible() and not area.horizontalScrollBar().isVisible(), size
edit(w, "CHR_SynthFoley_1")
assert not w.editor.subtitle_box.isVisible() # no subtitle: no subtitle box
def test_recorder_sample_formats(qapp):
@@ -456,3 +478,97 @@ def test_recorder_sample_formats(qapp):
r._ingest(raw[3:])
got, _ = r.stop()
assert np.allclose(got, x, atol=1 / 128), sample
def test_edit_list_replacement_effects_apply_revert(win, tmp_path):
"""Edit tab: sounds come from the browser onto the edit list; a replacement and effect settings are kept per sound
(user data), applied with the effects, reverted byte-identically; Previous / Next walk the list."""
from ackit import fx
w, root, _ = win
e = w.edit
before = files(root)
a, b = w.ws.sound("Speech_Synth_Radio_02"), w.ws.sound("Speech_TAPE_JOKER_1")
w.edit_sounds([a, b])
assert w.tabs.currentWidget() is e and [s.key for s in e.model.sounds] == [a.key, b.key]
ed = w.editor
assert ed.sound is a and not ed.b_prev.isEnabled() and ed.b_next.isEnabled()
assert ed.b_apply.isEnabled() and ed.b_export.isEnabled() and ed.b_rec.isEnabled()
wav = str(tmp_path / "new.wav")
audio.save_wav(wav, *synth.tone(1.2, 44100, 440))
assert ed.load_file(wav)
assert e.store.has_replacement(a.key) and e.model.state(a) == "Replacement saved"
dry = ed.processed()[0].copy()
e.rack.apply_look("Radio") # EQ, distortion, static
assert fx.summary(ed.effects) == "Band-pass EQ, Distortion, Noise"
wet = ed.processed()[0]
assert not np.allclose(wet[:len(dry)], dry, atol=1e-3)
e.flush()
assert e.store.get(a.key)["fx"]["eq"]["on"] is True
ed.apply()
assert w.ws.state(a) == W.MODIFIED and w.ws.store.get(a.key)["meta"]["effects"] == fx.summary(ed.effects)
assert a.key in w.model.modified and e.model.state(a) == "Applied"
QTest.qWait(50)
ed.b_next.click() # the next sound: no replacement, no effects
assert ed.sound is b and ed.src is None and not e.rack.is_on("eq") and ed.b_prev.isEnabled()
ed.b_prev.click() # back: replacement, values and effects return
assert ed.sound is a and ed.src is not None and e.rack.is_on("eq") and ed.cb_match.isChecked()
ed.revert()
QTest.qWait(50)
assert files(root) == before and not w.ws.store.entries
assert e.store.has_replacement(a.key) # the replacement stays for later
e.table.selectRow(1)
e.remove_selected()
assert [s.key for s in e.model.sounds] == [a.key] and e.store.list == [a.key]
def test_effects_panel_looks_presets_sliders(win):
from PySide6.QtCore import QItemSelectionModel
from ackit import fx
w, _, _ = win
w.edit_sounds([w.ws.sound("CHR_RadioScanner_1"), w.ws.sound("CHR_RadioScanner_3")])
rack = w.edit.rack
rack.apply_preset("reverb", "Hall")
s = rack.settings()
assert s["reverb"]["on"] and s["reverb"]["decay"] == 2.0 and rack.stack.currentWidget() is rack.pages["reverb"]
rack.show_block("echo")
rack.controls[("echo", "time")].slider.setValue(100) # moving a slider turns its effect on
assert rack.settings()["echo"]["on"] and rack.items["echo"].font().bold()
rack.set_on("echo", False)
assert not rack.settings()["echo"]["on"]
c = rack.controls[("eq", "low_cut")] # log slider, readable value
c.slider.setValue(c.slider.maximum())
assert rack.settings()["eq"]["low_cut"] == 2000 and c.value_lbl.text() == "2.0 kHz"
c.slider.reset.emit() # double-click: back to the default
assert rack.settings()["eq"]["low_cut"] == 300
rack.cmb_preset.activated.emit(rack.cmb_preset.findText("PA announcement")) # the preset list
assert rack.cmb_preset.currentIndex() == 0
s = rack.settings()
assert [b.key for b in fx.BLOCKS if s[b.key]["on"]] == ["eq", "echo", "channels"] and s["echo"]["time"] == 160
rack.b_copy.click()
rack.b_reset.click()
assert not fx.active(rack.settings())
rack.b_paste.click()
assert rack.settings()["echo"]["time"] == 160
sm = w.edit.table.selectionModel()
for r in range(w.edit.model.rowCount()):
sm.select(w.edit.model.index(r, 0), QItemSelectionModel.SelectionFlag.Select |
QItemSelectionModel.SelectionFlag.Rows)
w.edit.use_for_selected()
assert all(fx.summary(w.edit.store.get(k)["fx"]) == "Band-pass EQ, Echo, Channels"
for k in w.edit.store.list)
rack.set_channels(5)
assert "L R C SL SR" in rack.items["channels"].text()
def test_edit_store_moves_data_of_the_first_versions(qapp, tmp_path):
"""Replacements saved by the first versions (a "take" field, the takes/ folder) are found under the new names."""
import json
from app.edit import EditStore
root = tmp_path / "edits"
(root / "takes").mkdir(parents=True)
key = "INT:B49CDF48:1"
audio.save_wav(str(root / "takes" / "INT_B49CDF48_1.wav"), *synth.tone(0.2, 22050, 300))
json.dump({key: {"take": "recording", "gain": 1.5}}, open(root / "index.json", "w")) # first version: no list
st = EditStore(str(root))
assert st.list == [key] and st.get(key)["source"] == "recording" and "take" not in st.get(key)
assert st.has_replacement(key) and st.load_replacement(key) is not None and not (root / "takes").exists()