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:
2026-06-12 23:22:47 -04:00
parent 0a21297f77
commit bfe1434f75
10 changed files with 1100 additions and 352 deletions
+148 -4
View File
@@ -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() == []
+6
View File
@@ -32,6 +32,7 @@ def mock_presets():
pm = MagicMock()
pm.current_bank = 0
pm.current_program = 0
pm._current_snapshot = 0
pm.load.return_value = Preset(name="Default", bank=0, program=0)
return pm
@@ -41,6 +42,11 @@ def mock_pipeline():
pl = MagicMock()
pl._bypassed = False
pl._tuner_enabled = False
pl._tuner_frequency = 0.0
pl._tuner_note = '--'
pl._tuner_cents = 0
pl._tuner_string = -1
pl._tuner_confidence = 0.0
pl._master_volume = 0.8
pl.routing_mode = "mono"
pl.routing_breakpoint = 7