feat: separate preset banks per channel (guitar/bass)
- Channel enum (GUITAR, BASS) with channel field on Preset dataclass
- Channel-prefixed storage: {root}/{channel}/bank_{bank}/preset_{program}.json
- PresetManager channel awareness: set_channel(), current_channel property
- Per-channel state persistence (channel_state.json per channel dir)
- Legacy migration: flat bank_* dirs auto-migrate to guitar/ on first boot
- All CRUD methods accept optional channel parameter
- API endpoints accept channel param on GET/PUT/DELETE /api/presets
- /api/channel GET/POST for channel switching
- 10 new channel tests (independence, switching, migration, scoping)
- Factory presets install to specified channel
This commit is contained in:
@@ -27,7 +27,7 @@ from src.presets.manager import (
|
||||
_preset_from_dict,
|
||||
_preset_to_dict,
|
||||
)
|
||||
from src.presets.types import FXBlock, FXType, MIDIMapping, Preset
|
||||
from src.presets.types import Channel, FXBlock, FXType, MIDIMapping, Preset
|
||||
|
||||
|
||||
# ── Fixtures ────────────────────────────────────────────────────────────────
|
||||
@@ -152,7 +152,7 @@ class TestSaveLoad:
|
||||
|
||||
def test_save_creates_json_file(self, manager: PresetManager, sample_preset: Preset, preset_dir: Path):
|
||||
manager.save(sample_preset)
|
||||
path = preset_dir / "bank_0" / "preset_0.json"
|
||||
path = preset_dir / "guitar" / "bank_0" / "preset_0.json"
|
||||
assert path.exists()
|
||||
content = path.read_text(encoding="utf-8")
|
||||
assert "Crunch Rhythm" in content
|
||||
@@ -513,8 +513,152 @@ class TestEdgeCases:
|
||||
assert manager.current_program == 1
|
||||
|
||||
def test_corrupted_json_raises(self, manager: PresetManager, preset_dir: Path):
|
||||
path = preset_dir / "bank_0" / "preset_0.json"
|
||||
path = preset_dir / "guitar" / "bank_0" / "preset_0.json"
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_text("not valid json", encoding="utf-8")
|
||||
with pytest.raises(json.JSONDecodeError):
|
||||
manager.load(0, 0)
|
||||
manager.load(0, 0)
|
||||
|
||||
|
||||
# ── Channel independence ────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestChannelIndependence:
|
||||
"""Guitar and bass channels each have their own preset bank namespace."""
|
||||
|
||||
def test_channel_serialization_round_trip(self):
|
||||
"""Channel field survives to_dict / from_dict."""
|
||||
p = Preset(name="GTR Lead", channel=Channel.GUITAR, bank=0, program=0)
|
||||
d = _preset_to_dict(p)
|
||||
assert d["channel"] == "guitar"
|
||||
restored = _preset_from_dict(d)
|
||||
assert restored.channel == Channel.GUITAR
|
||||
|
||||
p2 = Preset(name="Bass Slap", channel=Channel.BASS, bank=0, program=0)
|
||||
d2 = _preset_to_dict(p2)
|
||||
assert d2["channel"] == "bass"
|
||||
restored2 = _preset_from_dict(d2)
|
||||
assert restored2.channel == Channel.BASS
|
||||
|
||||
def test_channel_defaults_to_guitar(self):
|
||||
"""Presets without explicit channel default to guitar."""
|
||||
p = Preset(name="Default", bank=0, program=0)
|
||||
assert p.channel == Channel.GUITAR
|
||||
|
||||
def test_guitar_and_bass_independent_banks(self, manager: PresetManager):
|
||||
"""Guitar and bass presets in the same (bank, program) don't collide."""
|
||||
gtr = Preset(name="GTR Crunch", channel=Channel.GUITAR, bank=0, program=0)
|
||||
bass = Preset(name="Bass Slap", channel=Channel.BASS, bank=0, program=0)
|
||||
|
||||
# Save guitar via current channel (defaults to guitar)
|
||||
manager.save(gtr)
|
||||
# Save bass explicitly
|
||||
manager.save(bass, channel=Channel.BASS)
|
||||
|
||||
# Load guitar
|
||||
loaded_gtr = manager.load(0, 0, Channel.GUITAR)
|
||||
assert loaded_gtr.name == "GTR Crunch"
|
||||
assert loaded_gtr.channel == Channel.GUITAR
|
||||
|
||||
# Load bass
|
||||
loaded_bass = manager.load(0, 0, Channel.BASS)
|
||||
assert loaded_bass.name == "Bass Slap"
|
||||
assert loaded_bass.channel == Channel.BASS
|
||||
|
||||
def test_channel_switch_restores_preset(self, manager: PresetManager):
|
||||
"""Switching channels restores the last active state for that channel."""
|
||||
manager.save(Preset(name="GTR Lead", channel=Channel.GUITAR, bank=0, program=0))
|
||||
manager.save(Preset(name="GTR Clean", channel=Channel.GUITAR, bank=0, program=1))
|
||||
manager.save(Preset(name="Bass Slap", channel=Channel.BASS, bank=1, program=2))
|
||||
|
||||
# Select guitar preset (bank=0, program=1)
|
||||
manager.select(0, 1, Channel.GUITAR)
|
||||
|
||||
# Select bass preset so its channel state is saved
|
||||
manager.select(1, 2, Channel.BASS)
|
||||
|
||||
# Switch to guitar — should go to (0, 1)
|
||||
manager.set_channel(Channel.GUITAR)
|
||||
assert manager.current_channel == Channel.GUITAR
|
||||
assert manager.current_bank == 0
|
||||
assert manager.current_program == 1
|
||||
|
||||
# Switch back to bass — should restore (1, 2)
|
||||
manager.set_channel(Channel.BASS)
|
||||
assert manager.current_channel == Channel.BASS
|
||||
assert manager.current_bank == 1
|
||||
assert manager.current_program == 2
|
||||
|
||||
def test_set_same_channel_noop(self, manager: PresetManager):
|
||||
"""set_channel to the same channel preserves current position."""
|
||||
manager.save(Preset(name="Only", channel=Channel.GUITAR, bank=0, program=2))
|
||||
manager.select(0, 2, Channel.GUITAR)
|
||||
result = manager.set_channel(Channel.GUITAR)
|
||||
assert result.name == "Only"
|
||||
assert manager.current_bank == 0
|
||||
assert manager.current_program == 2
|
||||
|
||||
def test_current_channel_property(self, manager: PresetManager):
|
||||
"""current_channel returns the active channel."""
|
||||
assert manager.current_channel == Channel.GUITAR
|
||||
manager.set_channel(Channel.BASS)
|
||||
assert manager.current_channel == Channel.BASS
|
||||
manager.set_channel(Channel.GUITAR)
|
||||
assert manager.current_channel == Channel.GUITAR
|
||||
|
||||
def test_list_banks_channel_scoped(self, manager: PresetManager):
|
||||
"""list_banks only returns banks for the specified channel."""
|
||||
manager.save(Preset(name="GTR_A", channel=Channel.GUITAR, bank=0, program=0))
|
||||
manager.save(Preset(name="BASS_A", channel=Channel.BASS, bank=5, program=0))
|
||||
|
||||
gtr_banks = manager.list_banks(Channel.GUITAR)
|
||||
assert len(gtr_banks) == 1
|
||||
assert gtr_banks[0].number == 0
|
||||
|
||||
bass_banks = manager.list_banks(Channel.BASS)
|
||||
assert len(bass_banks) == 1
|
||||
assert bass_banks[0].number == 5
|
||||
|
||||
def test_migrate_legacy_presets(self, tmp_path: Path):
|
||||
"""Legacy flat bank_* dirs are migrated to guitar/ on first boot."""
|
||||
# Create legacy layout: bank_0/preset_0.json at root
|
||||
legacy_bank = tmp_path / "bank_0"
|
||||
legacy_bank.mkdir()
|
||||
(legacy_bank / "preset_0.json").write_text(
|
||||
json.dumps({"name": "Legacy Tone", "bank": 0, "program": 0}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# Create PresetManager — should migrate
|
||||
pm = PresetManager(preset_dir=str(tmp_path), audio_pipeline=None)
|
||||
|
||||
# Old location should be gone
|
||||
assert not (tmp_path / "bank_0").exists()
|
||||
|
||||
# New location should exist
|
||||
guitar_path = tmp_path / "guitar" / "bank_0" / "preset_0.json"
|
||||
assert guitar_path.exists()
|
||||
|
||||
# Preset should be loadable from guitar channel
|
||||
preset = pm.load(0, 0, Channel.GUITAR)
|
||||
assert preset.name == "Legacy Tone"
|
||||
|
||||
def test_migration_skips_when_already_migrated(self, tmp_path: Path):
|
||||
"""Migration doesn't run if guitar/ dir already exists."""
|
||||
guitar_dir = tmp_path / "guitar"
|
||||
guitar_dir.mkdir(parents=True)
|
||||
(guitar_dir / "bank_0").mkdir()
|
||||
(guitar_dir / "bank_0" / "preset_0.json").write_text(
|
||||
json.dumps({"name": "Already Migrated", "channel": "guitar", "bank": 0, "program": 0}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
pm = PresetManager(preset_dir=str(tmp_path), audio_pipeline=None)
|
||||
preset = pm.load(0, 0, Channel.GUITAR)
|
||||
assert preset.name == "Already Migrated"
|
||||
|
||||
def test_fresh_install_no_migration(self, tmp_path: Path):
|
||||
"""Fresh install with no files doesn't raise or create guitar dir."""
|
||||
pm = PresetManager(preset_dir=str(tmp_path), audio_pipeline=None)
|
||||
assert not (tmp_path / "guitar").exists()
|
||||
assert pm.list_banks() == []
|
||||
Reference in New Issue
Block a user