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:
@@ -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()
|
||||
|
||||
@@ -35,6 +35,22 @@ def test_cli_workflow(synth_game, tmp_path, capsys):
|
||||
run(capsys, "revert", "Speech_Synth_Radio_02", *g)
|
||||
|
||||
|
||||
def test_cli_replace_with_voice_effects(synth_game, tmp_path, capsys):
|
||||
import json
|
||||
root, _ = synth_game
|
||||
g = ["--game", root, "--lang", "INT", "--store", str(tmp_path / "store")]
|
||||
tpl = str(tmp_path / "fx.json")
|
||||
assert "wrote" in run(capsys, "fx-template", tpl)
|
||||
s = json.load(open(tpl))
|
||||
s["eq"]["on"] = True
|
||||
json.dump(s, open(tpl, "w"))
|
||||
wav = str(tmp_path / "line.wav")
|
||||
audio.save_wav(wav, *synth.tone(1.0, 44100, 660))
|
||||
assert "replaced Speech_Synth_Radio_02" in run(capsys, "replace", "Speech_Synth_Radio_02", wav, "--fx", tpl, *g)
|
||||
run(capsys, "revert", "--all", *g)
|
||||
assert "game files are original" in run(capsys, "status", *g)
|
||||
|
||||
|
||||
def test_cli_text_language_without_voices(synth_game, capsys):
|
||||
"""--lang works for the languages that have only texts (INT voices are loaded for them, like the editor)."""
|
||||
root, _ = synth_game
|
||||
|
||||
225
tests/test_fx.py
Normal file
225
tests/test_fx.py
Normal file
@@ -0,0 +1,225 @@
|
||||
"""Effects (ackit/fx.py): each block measured back on synthetic signals."""
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from ackit import fx
|
||||
|
||||
SR = 44100
|
||||
|
||||
|
||||
def tone(freq, seconds=1.0, amp=0.3):
|
||||
t = np.arange(int(SR * seconds)) / SR
|
||||
return (amp * np.sin(2 * np.pi * freq * t)).astype(np.float32)[:, None]
|
||||
|
||||
|
||||
def impulse(seconds=1.5, at=0.1):
|
||||
x = np.zeros((int(SR * seconds), 1), np.float32)
|
||||
x[int(SR * at)] = 1.0
|
||||
return x
|
||||
|
||||
|
||||
def only(key, **params):
|
||||
s = fx.defaults()
|
||||
s[key]["on"] = True
|
||||
s[key].update(params)
|
||||
return s
|
||||
|
||||
|
||||
def peak_freq(x, lo=20, hi=None):
|
||||
spec = np.abs(np.fft.rfft(x[:, 0] * np.hanning(len(x))))
|
||||
f = np.fft.rfftfreq(len(x), 1 / SR)
|
||||
m = (f >= lo) & (f <= (hi or SR / 2))
|
||||
return f[m][np.argmax(spec[m])]
|
||||
|
||||
|
||||
def band_db(x, f0, width=0.05):
|
||||
spec = np.abs(np.fft.rfft(x[:, 0] * np.hanning(len(x)))) ** 2
|
||||
f = np.fft.rfftfreq(len(x), 1 / SR)
|
||||
m = (f > f0 * (1 - width)) & (f < f0 * (1 + width))
|
||||
return 10 * np.log10(spec[m].sum() + 1e-20)
|
||||
|
||||
|
||||
def test_defaults_off_and_identity():
|
||||
s = fx.defaults()
|
||||
assert not fx.active(s) and fx.summary(s) == ""
|
||||
x = tone(440)
|
||||
assert np.array_equal(fx.render(x, SR, s), x)
|
||||
|
||||
|
||||
def test_normalize_fills_clamps_and_drops():
|
||||
s = fx.normalize({"eq": {"on": 1, "low_cut": 5, "bogus": 3}, "pitch": {"shift": "x"}, "nope": {}})
|
||||
assert s["eq"]["on"] is True and s["eq"]["low_cut"] == 20 and "bogus" not in s["eq"]
|
||||
assert s["pitch"]["shift"] == 0.0 and "nope" not in s
|
||||
assert s["glitch"]["repeats"] == 2 and isinstance(s["glitch"]["repeats"], int)
|
||||
assert fx.summary(s) == "Band-pass EQ"
|
||||
|
||||
|
||||
def test_pitch_shift_octave_up():
|
||||
y = fx.render(tone(220), SR, only("pitch", shift=12))
|
||||
assert abs(peak_freq(y) - 440) < 10
|
||||
|
||||
|
||||
def test_formant_shift_keeps_pitch():
|
||||
t = np.arange(SR) / SR
|
||||
x = sum(np.sin(2 * np.pi * 150 * k * t) / k for k in range(1, 20)).astype(np.float32)[:, None] * 0.1
|
||||
y = fx.render(x, SR, only("pitch", formant=6))
|
||||
assert abs(peak_freq(y, 100, 200) - 150) < 5
|
||||
assert not np.allclose(y, x, atol=1e-3)
|
||||
|
||||
|
||||
def test_pitch_drift_moves_over_time():
|
||||
y = fx.render(tone(440, 4), SR, only("pitch", drift=4, drift_rate=0.5))
|
||||
peaks = [peak_freq(y[i:i + 8192]) for i in range(SR // 2, len(y) - 8192, SR // 4)]
|
||||
assert max(peaks) - min(peaks) > 100
|
||||
|
||||
|
||||
def test_octave_layer_below():
|
||||
y = fx.render(tone(440), SR, only("octaves", interval1=-12, level1=0))
|
||||
assert band_db(y, 220) > band_db(tone(440), 220) + 30
|
||||
|
||||
|
||||
def test_ring_modulator_sidebands():
|
||||
y = fx.render(tone(1000), SR, only("robot", ring_freq=100, ring_mix=1.0))
|
||||
assert band_db(y, 900, 0.01) > band_db(y, 1000, 0.01) + 20
|
||||
assert band_db(y, 1100, 0.01) > band_db(y, 1000, 0.01) + 20
|
||||
|
||||
|
||||
def test_glitch_longer_and_seeded():
|
||||
x = tone(300, 3)
|
||||
a = fx.render(x, SR, only("glitch", rate=5, length=100, repeats=2, pitch=0, seed=1))
|
||||
b = fx.render(x, SR, only("glitch", rate=5, length=100, repeats=2, pitch=0, seed=1))
|
||||
c = fx.render(x, SR, only("glitch", rate=5, length=100, repeats=2, pitch=0, seed=2))
|
||||
assert np.array_equal(a, b)
|
||||
assert len(a) > len(x) and len(a) - len(x) == int(SR * 0.1) * 2 * round(5 * 3 / 10)
|
||||
assert not np.array_equal(a, c[:len(a)]) or len(a) != len(c)
|
||||
|
||||
|
||||
def test_eq_band_pass():
|
||||
x = (tone(100) + tone(1500) + tone(8000)) / 3
|
||||
y = fx.render(x, SR, only("eq", low_cut=300, high_cut=3400, slope=24, mid_gain=0))
|
||||
assert band_db(y, 100) < band_db(x, 100) - 20
|
||||
assert band_db(y, 8000) < band_db(x, 8000) - 20
|
||||
assert abs(band_db(y, 1500) - band_db(x, 1500)) < 0.5
|
||||
|
||||
|
||||
def test_distortion_adds_harmonics_same_loudness():
|
||||
x = tone(200)
|
||||
y = fx.render(x, SR, only("distortion", drive=20))
|
||||
assert band_db(y, 600) > band_db(x, 600) + 20
|
||||
assert abs(np.sqrt(np.mean(y ** 2)) - np.sqrt(np.mean(x ** 2))) < 1e-3
|
||||
|
||||
|
||||
@pytest.mark.parametrize("opt", [{"clip": -12, "drive": 0}, {"bits": 4, "drive": 0}, {"gsm": True, "drive": 0}])
|
||||
def test_distortion_options_change_audio(opt):
|
||||
x = tone(200)
|
||||
assert not np.allclose(fx.render(x, SR, only("distortion", **opt)), x, atol=1e-3)
|
||||
|
||||
|
||||
def test_noise_level_relative_to_voice():
|
||||
x = tone(500, 2)
|
||||
y = fx.render(x, SR, only("noise", level=-20, colour=2000))
|
||||
n = y - x
|
||||
ratio = 20 * np.log10(np.sqrt(np.mean(n ** 2)) / np.sqrt(np.mean(x ** 2)))
|
||||
assert abs(ratio + 20) < 1
|
||||
|
||||
|
||||
def test_echo_delay_and_tail():
|
||||
x = impulse()
|
||||
y = fx.render(x, SR, only("echo", time=160, feedback=0, mix=0.5))
|
||||
assert len(y) >= len(x)
|
||||
d = np.argsort(np.abs(y[:, 0]))[-2:]
|
||||
assert abs(sorted(d)[1] - sorted(d)[0] - int(SR * 0.16)) <= 1 # pedalboard rounds the delay
|
||||
|
||||
|
||||
def test_reverb_tail_and_reverse_swell():
|
||||
x = impulse()
|
||||
y = fx.render(x, SR, only("reverb", mix=0.5, decay=1.0))
|
||||
assert np.sum(y[int(SR * 0.3):] ** 2) > 1e-4
|
||||
v = np.zeros((SR * 2, 1), np.float32)
|
||||
v[SR:SR + SR // 2] = tone(300, 0.5)
|
||||
r = fx.render(v, SR, only("reverb", mix=0.0, reverse=1.0, reverse_every=1.0))
|
||||
assert np.sum(r[SR // 2:SR] ** 2) > 1e-3 and np.sum(v[SR // 2:SR] ** 2) == 0
|
||||
|
||||
|
||||
def test_channels_pa_layout():
|
||||
x = impulse(1.0)
|
||||
s = only("channels", front=-12, surround=-6, surround_delay=160, width=0)
|
||||
y = fx.render(x, SR, s, channels=5) # L R C SL SR
|
||||
assert y.shape[1] == 5
|
||||
i = int(SR * 0.1)
|
||||
assert y[i, 2] == pytest.approx(1.0)
|
||||
assert y[i, 0] == pytest.approx(10 ** (-12 / 20), rel=1e-3)
|
||||
assert np.argmax(np.abs(y[:, 3])) == i + int(SR * 0.16)
|
||||
assert np.max(np.abs(y[:, 3])) == pytest.approx(10 ** (-6 / 20), rel=1e-3)
|
||||
assert fx.render(x, SR, s, channels=1).shape[1] == 1 # mono target: block has no effect
|
||||
|
||||
|
||||
def test_channels_width_and_pan():
|
||||
y = fx.render(impulse(1.0), SR, only("channels", width=10), channels=2)
|
||||
assert np.argmax(np.abs(y[:, 1])) - np.argmax(np.abs(y[:, 0])) == int(SR * 0.01)
|
||||
p = fx.render(tone(300, 2), SR, only("channels", pan_rate=1, pan_depth=1), channels=2)
|
||||
left = [np.sqrt(np.mean(p[i:i + 2000, 0] ** 2)) for i in range(0, len(p) - 2000, 4410)]
|
||||
assert max(left) > 2 * min(left) + 1e-3
|
||||
|
||||
|
||||
def test_all_blocks_together_finite():
|
||||
s = fx.defaults()
|
||||
for b in fx.BLOCKS:
|
||||
s[b.key]["on"] = True
|
||||
s["distortion"]["gsm"] = True
|
||||
x = tone(220, 2) + tone(1300, 2) * 0.3
|
||||
y = fx.render(x, SR, s, channels=4)
|
||||
assert y.shape[1] == 4 and np.isfinite(y).all() and len(y) >= len(x)
|
||||
assert np.array_equal(y, fx.render(x, SR, s, channels=4)) # deterministic
|
||||
|
||||
|
||||
def rt60(ir):
|
||||
"""Reverb time from the Schroeder backward integral (-5 to -25 dB, extrapolated to -60)."""
|
||||
e = np.cumsum((ir ** 2)[::-1])[::-1]
|
||||
db = 10 * np.log10(e / e[0] + 1e-30)
|
||||
a, b = np.argmax(db < -5), np.argmax(db < -25)
|
||||
return (b - a) / SR * 3
|
||||
|
||||
|
||||
@pytest.mark.parametrize("decay", [0.3, 1.0, 3.0])
|
||||
def test_room_response_decay_measured_back(decay):
|
||||
ir = fx.room_response(SR, 1, decay, predelay=0, early=0.0, damping=0.0)
|
||||
assert abs(rt60(ir[:, 0]) - decay) < 0.15 * decay
|
||||
assert np.sum(ir ** 2) == pytest.approx(1.0, rel=1e-3)
|
||||
|
||||
|
||||
def test_room_response_damping_predelay_width():
|
||||
ir = fx.room_response(SR, 1, 1.5, predelay=0, early=0.0, damping=1.0)[:, 0]
|
||||
late = ir[int(0.5 * SR):][:, None]
|
||||
assert band_db(late, 8000, 0.3) < band_db(late, 300, 0.3) - 20 # the highs die first
|
||||
pre = fx.room_response(SR, 1, 0.5, predelay=40)[:, 0]
|
||||
assert np.max(np.abs(pre[:int(0.039 * SR)])) == 0 and np.max(np.abs(pre[int(0.04 * SR):])) > 0
|
||||
wide = fx.room_response(SR, 2, 0.5, width=1.0)
|
||||
mono = fx.room_response(SR, 2, 0.5, width=0.0)
|
||||
assert abs(np.corrcoef(wide.T)[0, 1]) < 0.2 and np.corrcoef(mono.T)[0, 1] > 0.99
|
||||
|
||||
|
||||
def test_presets_and_looks_are_valid():
|
||||
for block, items in fx.PRESETS.items():
|
||||
keys = {p.key for p in fx.BY_KEY[block].params}
|
||||
for name, values in items:
|
||||
assert set(values) <= keys, (block, name)
|
||||
s = fx.with_preset(fx.defaults(), block, name)
|
||||
assert s[block]["on"] and all(s[block][k] == values[k] for k in values), (block, name)
|
||||
x = tone(300, 1)
|
||||
for name, _ in fx.LOOKS:
|
||||
s = fx.look(name)
|
||||
assert fx.active(s)
|
||||
y = fx.render(x, SR, s, channels=5)
|
||||
assert np.isfinite(y).all() and len(y) >= len(x)
|
||||
|
||||
|
||||
def test_reverb_mix_dry_to_wet_and_old_name():
|
||||
x = impulse(1.0)
|
||||
i = int(SR * 0.1)
|
||||
assert np.array_equal(fx.render(x, SR, only("reverb", mix=0.0)), x) # 0: dry only
|
||||
wet = fx.render(x, SR, only("reverb", mix=1.0, predelay=30, early=0.0))
|
||||
assert abs(wet[i, 0]) < 1e-3 and np.max(np.abs(wet[i + int(SR * 0.03):])) > 0 # 1: no dry impulse left
|
||||
half = fx.render(x, SR, only("reverb", mix=0.5, predelay=30, early=0.0))
|
||||
assert half[i, 0] == pytest.approx(np.cos(np.pi / 4), rel=1e-3) # equal power
|
||||
assert fx.normalize({"reverb": {"on": True, "wet": 0.4}})["reverb"]["mix"] == 0.4 # older settings
|
||||
Reference in New Issue
Block a user